diff --git a/admin/formatter/class-metabox-formatter.php b/admin/formatter/class-metabox-formatter.php index 0a765c2d641..a83fbee0741 100644 --- a/admin/formatter/class-metabox-formatter.php +++ b/admin/formatter/class-metabox-formatter.php @@ -48,7 +48,8 @@ public function get_values() { * @return array|bool|int> Default settings for the metabox. */ private function get_defaults() { - $schema_types = new Schema_Types(); + $schema_types = new Schema_Types(); + $schema_fields_defs = WPSEO_Meta::get_meta_field_defs( 'schema' ); $defaults = [ 'author_name' => get_the_author_meta( 'display_name' ), @@ -59,6 +60,9 @@ private function get_defaults() { 'displayFooter' => WPSEO_Capability_Utils::current_user_can( 'wpseo_manage_options' ), 'pageTypeOptions' => $schema_types->get_page_type_options(), 'articleTypeOptions' => $schema_types->get_article_type_options(), + 'defaultPageType' => isset( $schema_fields_defs['schema_page_type'] ) ? $schema_fields_defs['schema_page_type']['default'] : '', + 'defaultArticleType' => isset( $schema_fields_defs['schema_article_type'] ) ? $schema_fields_defs['schema_article_type']['default'] : '', + 'showArticleInput' => isset( $schema_fields_defs['schema_article_type'] ), ], 'twitterCardType' => 'summary_large_image', /** diff --git a/admin/metabox/class-metabox.php b/admin/metabox/class-metabox.php index abee35f247b..26361d4f363 100644 --- a/admin/metabox/class-metabox.php +++ b/admin/metabox/class-metabox.php @@ -232,6 +232,10 @@ public function display_metabox( $identifier = null, $type = 'post_type' ) { * @return void */ public function add_meta_box() { + if ( $this->is_metabox_disabled_in_block_editor() ) { + return; + } + $post_types = WPSEO_Post_Type::get_accessible_post_types(); $post_types = array_filter( $post_types, [ $this, 'display_metabox' ] ); @@ -330,10 +334,38 @@ private function determine_scope() { * @return void */ public function meta_box() { - $this->render_hidden_fields(); + if ( ! $this->is_metabox_disabled_in_block_editor() ) { + $this->render_hidden_fields(); + } $this->render_tabs(); } + /** + * Returns whether the metabox hidden fields and $_POST-based save are disabled for the block editor. + * + * When this returns true the block editor uses the REST API meta path instead: all WPSEO post meta + * fields are registered with show_in_rest, so core/editor carries and saves them automatically. + * + * Filter: 'wpseo_disable_metabox_in_block_editor' + * + * @return bool + */ + protected function is_metabox_disabled_in_block_editor() { + $screen = WP_Screen::get(); + if ( ! $screen || ! $screen->is_block_editor() ) { + return false; + } + + /** + * Filter: 'wpseo_disable_metabox_in_block_editor' - Disables the Yoast metabox hidden fields + * and the $_POST-based save hook in the block editor, so that post meta is saved via the + * WordPress REST API instead (requires all relevant fields to have show_in_rest enabled). + * + * @param bool $disable Whether to disable the metabox. Default false. + */ + return (bool) apply_filters( 'wpseo_disable_metabox_in_block_editor', false ); + } + /** * Renders the metabox hidden fields. * @@ -700,6 +732,18 @@ public function save_postdata( $post_id ) { return false; } + // Bail when the REST meta path is active and there is no metabox form submission. + // core/editor persists meta via the REST API, so $_POST will not contain the Yoast + // nonce or hidden-field values. Checking for the nonce absence ensures we only skip + // when there is genuinely no $_POST-based save to process. + // Note: is_metabox_disabled_in_block_editor() cannot be used here because WP_Screen + // is not initialised during REST requests, so the filter is called directly instead. + if ( apply_filters( 'wpseo_disable_metabox_in_block_editor', false ) + && ( defined( 'REST_REQUEST' ) && REST_REQUEST ) + && ! isset( $_POST['yoast_free_metabox_nonce'] ) ) { + return false; + } + if ( $post_id === null ) { return false; } @@ -904,19 +948,20 @@ public function enqueue() { $is_front_page = $homepage_is_page && $page_on_front === (int) $post_id; $script_data = [ - 'metabox' => $this->get_metabox_script_data(), - 'isPost' => true, - 'isBlockEditor' => $is_block_editor, - 'postId' => $post_id, - 'postStatus' => get_post_status( $post_id ), - 'postType' => get_post_type( $post_id ), - 'isPage' => get_post_type( $post_id ) === 'page', - 'usedKeywordsNonce' => wp_create_nonce( 'wpseo-keyword-usage-and-post-types' ), - 'analysis' => [ + 'metabox' => $this->get_metabox_script_data(), + 'isPost' => true, + 'isBlockEditor' => $is_block_editor, + 'disableMetaboxInBlockEditor' => $is_block_editor && (bool) apply_filters( 'wpseo_disable_metabox_in_block_editor', false ), + 'postId' => $post_id, + 'postStatus' => get_post_status( $post_id ), + 'postType' => get_post_type( $post_id ), + 'isPage' => get_post_type( $post_id ) === 'page', + 'usedKeywordsNonce' => wp_create_nonce( 'wpseo-keyword-usage-and-post-types' ), + 'analysis' => [ 'plugins' => $plugins_script_data, 'worker' => $worker_script_data, ], - 'isFrontPage' => $is_front_page, + 'isFrontPage' => $is_front_page, ]; /** diff --git a/composer.json b/composer.json index 52ccba45ca3..f7a90852335 100644 --- a/composer.json +++ b/composer.json @@ -111,7 +111,7 @@ "Yoast\\WP\\SEO\\Composer\\Actions::check_coding_standards" ], "check-cs-thresholds": [ - "@putenv YOASTCS_THRESHOLD_ERRORS=2391", + "@putenv YOASTCS_THRESHOLD_ERRORS=2388", "@putenv YOASTCS_THRESHOLD_WARNINGS=257", "Yoast\\WP\\SEO\\Composer\\Actions::check_cs_thresholds" ], diff --git a/inc/class-wpseo-meta.php b/inc/class-wpseo-meta.php index e2030010c1f..dacf23923fe 100644 --- a/inc/class-wpseo-meta.php +++ b/inc/class-wpseo-meta.php @@ -103,22 +103,16 @@ class WPSEO_Meta { 'focuskw' => [ 'type' => 'hidden', 'title' => '', - 'show_in_rest' => true, - 'single' => true, ], 'title' => [ 'type' => 'hidden', 'default_value' => '', - 'show_in_rest' => true, - 'single' => true, ], 'metadesc' => [ 'type' => 'hidden', 'default_value' => '', 'class' => 'metadesc', 'rows' => 2, - 'show_in_rest' => true, - 'single' => true, ], 'linkdex' => [ 'type' => 'hidden', @@ -189,13 +183,6 @@ class WPSEO_Meta { 'options' => Schema_Types::ARTICLE_TYPES, ], ], - /* Fields we should validate & save, but not show on any form. */ - 'non_form' => [ - 'linkdex' => [ - 'type' => null, - 'default_value' => '0', - ], - ], 'content_planner' => [ 'is_content_planner_banner_rendered' => [ 'type' => 'hidden', @@ -287,24 +274,6 @@ public static function init() { [ 'sanitize_callback' => [ self::class, 'sanitize_post_meta' ] ], ); - // Re-register for the 'post' subtype with REST exposure and auth callback when show_in_rest is enabled. - if ( ! empty( $field_def['show_in_rest'] ) ) { - register_meta( - 'post', - self::$meta_prefix . $key, - [ - 'show_in_rest' => true, - 'single' => ( $field_def['single'] ?? false ), - 'type' => 'string', - 'object_subtype' => 'post', - 'sanitize_callback' => [ self::class, 'sanitize_post_meta' ], - 'auth_callback' => static function ( $allowed, $meta_key, $object_id ) { - return current_user_can( 'edit_post', $object_id ); - }, - ], - ); - } - // Set the $fields_index property for efficiency. self::$fields_index[ self::$meta_prefix . $key ] = [ 'subset' => $subset, @@ -323,12 +292,6 @@ public static function init() { } unset( $subset, $field_group, $key, $field_def ); - // Strip meta fields that have show_in_rest enabled from REST responses for users - // without edit_post capability. register_meta's auth_callback only covers writes, - // so read access must be restricted separately via this filter. - // Register only for 'post' post type. Other post types don't expose these fields. - add_filter( 'rest_prepare_post', [ self::class, 'hide_meta_from_unauthorized_rest_response' ], 10, 2 ); - self::filter_schema_article_types(); add_filter( 'update_post_metadata', [ self::class, 'remove_meta_if_default' ], 10, 5 ); @@ -1074,30 +1037,6 @@ public static function post_types_for_ids( $post_ids ) { return $post_types; } - /** - * Strips REST-exposed Yoast meta fields from the response for users without edit_post capability on the post. - * - * @param WP_REST_Response $response The REST response. - * @param WP_Post $post The post object. - * - * @return WP_REST_Response The (possibly modified) response. - */ - public static function hide_meta_from_unauthorized_rest_response( $response, $post ) { - if ( current_user_can( 'edit_post', $post->ID ) ) { - return $response; - } - $data = $response->get_data(); - foreach ( self::$meta_fields as $field_group ) { - foreach ( $field_group as $key => $field_def ) { - if ( ! empty( $field_def['show_in_rest'] ) ) { - unset( $data['meta'][ self::$meta_prefix . $key ] ); - } - } - } - $response->set_data( $data ); - return $response; - } - /** * Filter the schema article types. * diff --git a/inc/class-wpseo-primary-term.php b/inc/class-wpseo-primary-term.php index f5a5ccc22e5..12466d4bb7a 100644 --- a/inc/class-wpseo-primary-term.php +++ b/inc/class-wpseo-primary-term.php @@ -65,7 +65,7 @@ public function get_primary_term() { * @return void */ public function set_primary_term( $new_primary_term ) { - update_post_meta( $this->post_ID, WPSEO_Meta::$meta_prefix . 'primary_' . $this->taxonomy_name, $new_primary_term ); + update_post_meta( $this->post_ID, WPSEO_Meta::$meta_prefix . 'primary_' . $this->taxonomy_name, (string) $new_primary_term ); } /** diff --git a/packages/js/package.json b/packages/js/package.json index aed46393314..7d589ec1ddf 100644 --- a/packages/js/package.json +++ b/packages/js/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "cd ../.. && wp-scripts build --config config/webpack/webpack.config.js", "test": "jest", - "lint": "eslint . --max-warnings=40" + "lint": "eslint . --max-warnings=34" }, "dependencies": { "@draft-js-plugins/mention": "^5.0.0", diff --git a/packages/js/src/ai-content-planner/hooks/use-apply-outline.js b/packages/js/src/ai-content-planner/hooks/use-apply-outline.js index 05f45d4e57b..5a5c38ccab0 100644 --- a/packages/js/src/ai-content-planner/hooks/use-apply-outline.js +++ b/packages/js/src/ai-content-planner/hooks/use-apply-outline.js @@ -1,5 +1,6 @@ import { useCallback } from "@wordpress/element"; import { useDispatch, select } from "@wordpress/data"; +import { metaKeyTitle, metaKeyMetaDesc, metaKeyFocusKw } from "../../shared-admin/constants"; import { buildBlocksFromOutline } from "../helpers/build-blocks-from-outline"; import { CONTENT_PLANNER_STORE } from "../constants"; @@ -48,12 +49,9 @@ export const useApplyOutline = ( { editedOutlineRef } ) => { title: metaOutline.title, blocks: buildBlocksFromOutline( structure ), meta: { - // eslint-disable-next-line camelcase - _yoast_wpseo_title: metaOutline.title, - // eslint-disable-next-line camelcase - _yoast_wpseo_metadesc: metaOutline.metaDescription, - // eslint-disable-next-line camelcase - _yoast_wpseo_focuskw: metaOutline.focusKeyphrase, + [ metaKeyTitle ]: metaOutline.title, + [ metaKeyMetaDesc ]: metaOutline.metaDescription, + [ metaKeyFocusKw ]: metaOutline.focusKeyphrase, }, }; if ( metaOutline.category?.id && metaOutline.category.id !== -1 ) { diff --git a/packages/js/src/ai-content-planner/hooks/use-yoast-meta-sync.js b/packages/js/src/ai-content-planner/hooks/use-yoast-meta-sync.js index acf8932c343..a5d6cde47e2 100644 --- a/packages/js/src/ai-content-planner/hooks/use-yoast-meta-sync.js +++ b/packages/js/src/ai-content-planner/hooks/use-yoast-meta-sync.js @@ -1,5 +1,6 @@ import { useDispatch, useSelect } from "@wordpress/data"; import { useEffect } from "@wordpress/element"; +import { metaKeyTitle, metaKeyMetaDesc, metaKeyFocusKw } from "../../shared-admin/constants"; /** * Mirrors core/editor meta changes into yoast-seo/editor. Fires on every meta change, @@ -15,9 +16,9 @@ export function useYoastMetaSync() { const meta = editor.getEditedPostAttribute( "meta" ); const { title, description } = select( "yoast-seo/editor" ).getSnippetEditorTemplates(); return { - yoastTitle: meta?._yoast_wpseo_title, - yoastMetaDesc: meta?._yoast_wpseo_metadesc, - yoastFocusKw: meta?._yoast_wpseo_focuskw, + yoastTitle: meta?.[ metaKeyTitle ], + yoastMetaDesc: meta?.[ metaKeyMetaDesc ], + yoastFocusKw: meta?.[ metaKeyFocusKw ], isPost: editor.getCurrentPostType() === "post", titleTemplate: title, descTemplate: description, @@ -31,9 +32,9 @@ export function useYoastMetaSync() { if ( ! isPost ) { return; } - // Only sync non-empty values. An empty string means no custom value has been saved, in - // which case the snippet editor should keep showing the SEO title template instead of - // being overwritten with an empty string. + // Only sync non-empty values. An empty string means no custom value has been saved, + // in which case the snippet editor should keep showing the SEO title + // template instead of being overwritten with an empty string. const dataToSync = { title: titleTemplate, description: descTemplate, diff --git a/packages/js/src/analysis/PostDataCollector.js b/packages/js/src/analysis/PostDataCollector.js index 8b9e9b167fd..ae1aca696c0 100644 --- a/packages/js/src/analysis/PostDataCollector.js +++ b/packages/js/src/analysis/PostDataCollector.js @@ -12,6 +12,8 @@ import { update as updateAdminBar } from "../ui/adminBar"; import * as publishBox from "../ui/publishBox"; import { update as updateTrafficLight } from "../ui/trafficLight"; import * as tmceHelper from "../lib/tinymce"; +import AnalysisFields from "../helpers/fields/AnalysisFields"; +import SearchMetadataFields from "../helpers/fields/SearchMetadataFields"; import getIndicatorForScore from "./getIndicatorForScore"; import isKeywordAnalysisActive from "./isKeywordAnalysisActive"; import isContentAnalysisActive from "./isContentAnalysisActive"; @@ -80,9 +82,7 @@ PostDataCollector.prototype.getData = function() { * @returns {string} The keyword. */ PostDataCollector.prototype.getKeyword = function() { - var val = document.getElementById( "yoast_wpseo_focuskw" ) && document.getElementById( "yoast_wpseo_focuskw" ).value || ""; - - return val; + return AnalysisFields.keyphrase; }; /** @@ -106,7 +106,7 @@ PostDataCollector.prototype.getMetaDescForAnalysis = function( state ) { * @returns {string} The meta description. */ PostDataCollector.prototype.getMeta = function() { - return document.getElementById( "yoast_wpseo_metadesc" ) && document.getElementById( "yoast_wpseo_metadesc" ).value || ""; + return SearchMetadataFields.description; }; /** @@ -171,7 +171,7 @@ PostDataCollector.prototype.getExcerpt = function() { * @returns {string} The snippet title. */ PostDataCollector.prototype.getSnippetTitle = function() { - return document.getElementById( "yoast_wpseo_title" ) && document.getElementById( "yoast_wpseo_title" ).value || ""; + return SearchMetadataFields.title; }; /** @@ -180,7 +180,7 @@ PostDataCollector.prototype.getSnippetTitle = function() { * @returns {string} The snippet meta. */ PostDataCollector.prototype.getSnippetMeta = function() { - return document.getElementById( "yoast_wpseo_metadesc" ) && document.getElementById( "yoast_wpseo_metadesc" ).value || ""; + return SearchMetadataFields.description; }; /** @@ -263,6 +263,55 @@ PostDataCollector.prototype.getCategoryName = function( li ) { return clone.text().trim(); }; +/** + * Updates the snippet meta description field. + * + * @param {string} value The value to set. + * + * @returns {void} + */ +PostDataCollector.prototype.setSnippetMeta = function( value ) { + SearchMetadataFields.description = value; +}; + +/** + * Updates the snippet slug field. + * + * WordPress leaves the post name empty to signify that it should be generated from the title once the + * post is saved. So when we receive an auto generated slug from WordPress we should be + * able to not save this to the UI. This conditional makes that possible. + * + * @param {string} value The value to set. + * + * @returns {void} + */ +PostDataCollector.prototype.setSnippetCite = function( value ) { + if ( this.leavePostNameUntouched ) { + this.leavePostNameUntouched = false; + return; + } + if ( document.getElementById( "post_name" ) !== null ) { + document.getElementById( "post_name" ).value = value; + } + if ( + document.getElementById( "editable-post-name" ) !== null && + document.getElementById( "editable-post-name-full" ) !== null ) { + document.getElementById( "editable-post-name" ).textContent = value; + document.getElementById( "editable-post-name-full" ).textContent = value; + } +}; + +/** + * Updates the snippet title field. + * + * @param {string} value The value to set. + * + * @returns {void} + */ +PostDataCollector.prototype.setSnippetTitle = function( value ) { + SearchMetadataFields.title = value; +}; + /** * When the snippet is updated, update the (hidden) fields on the page. * @@ -274,31 +323,13 @@ PostDataCollector.prototype.getCategoryName = function( li ) { PostDataCollector.prototype.setDataFromSnippet = function( value, type ) { switch ( type ) { case "snippet_meta": - document.getElementById( "yoast_wpseo_metadesc" ).value = value; + this.setSnippetMeta( value ); break; case "snippet_cite": - - /* - * WordPress leaves the post name empty to signify that it should be generated from the title once the - * post is saved. So when we receive an auto generated slug from WordPress we should be - * able to not save this to the UI. This conditional makes that possible. - */ - if ( this.leavePostNameUntouched ) { - this.leavePostNameUntouched = false; - return; - } - if ( document.getElementById( "post_name" ) !== null ) { - document.getElementById( "post_name" ).value = value; - } - if ( - document.getElementById( "editable-post-name" ) !== null && - document.getElementById( "editable-post-name-full" ) !== null ) { - document.getElementById( "editable-post-name" ).textContent = value; - document.getElementById( "editable-post-name-full" ).textContent = value; - } + this.setSnippetCite( value ); break; case "snippet_title": - document.getElementById( "yoast_wpseo_title" ).value = value; + this.setSnippetTitle( value ); break; default: break; @@ -380,7 +411,7 @@ PostDataCollector.prototype.saveScores = function( score, keyword ) { publishBox.updateScore( "content", indicator.className ); - document.getElementById( "yoast_wpseo_linkdex" ).value = score; + AnalysisFields.seoScore = score; if ( "" === keyword ) { indicator.className = "na"; @@ -414,7 +445,7 @@ PostDataCollector.prototype.saveContentScore = function( score ) { updateAdminBar( indicator ); } - $( "#yoast_wpseo_content_score" ).val( score ); + AnalysisFields.readabilityScore = score; }; /** @@ -433,7 +464,7 @@ PostDataCollector.prototype.saveInclusiveLanguageScore = function( score ) { updateAdminBar( indicator ); } - $( "#yoast_wpseo_inclusive_language_score" ).val( score ); + AnalysisFields.inclusiveLanguageScore = score; }; diff --git a/packages/js/src/components/PrimaryTaxonomyPicker.js b/packages/js/src/components/PrimaryTaxonomyPicker.js index 485b3602d33..9e8f52cc788 100644 --- a/packages/js/src/components/PrimaryTaxonomyPicker.js +++ b/packages/js/src/components/PrimaryTaxonomyPicker.js @@ -6,6 +6,7 @@ import { addQueryArgs } from "@wordpress/url"; import { difference, noop } from "lodash"; import PropTypes from "prop-types"; import styled from "styled-components"; +import PrimaryTermFields from "../helpers/fields/PrimaryTermFields"; import TaxonomyPicker from "./TaxonomyPicker"; const PrimaryTaxonomyPickerField = styled.div` @@ -28,8 +29,8 @@ class PrimaryTaxonomyPicker extends Component { this.updateReplacementVariable = this.updateReplacementVariable.bind( this ); const { fieldId, name } = props.taxonomy; - this.input = document.getElementById( fieldId ); - const parsedPrimaryTaxonomyId = parseInt( this.input.value, 10 ); + const rawValue = PrimaryTermFields.get( name, fieldId ); + const parsedPrimaryTaxonomyId = parseInt( rawValue, 10 ); // Fallback to -1 when the field is empty or invalid to avoid dispatching NaN. props.setPrimaryTaxonomyId( name, Number.isNaN( parsedPrimaryTaxonomyId ) ? -1 : parsedPrimaryTaxonomyId ); @@ -182,13 +183,13 @@ class PrimaryTaxonomyPicker extends Component { * @returns {void} */ onChange( termId ) { - const { name } = this.props.taxonomy; + const { name, fieldId } = this.props.taxonomy; this.updateReplacementVariable( termId ); this.props.setPrimaryTaxonomyId( name, termId ); - this.input.value = termId === -1 ? "" : termId; + PrimaryTermFields.set( name, fieldId, termId ); } /** diff --git a/packages/js/src/containers/SchemaTab.js b/packages/js/src/containers/SchemaTab.js index 94e658c374f..9fa3ace6c80 100644 --- a/packages/js/src/containers/SchemaTab.js +++ b/packages/js/src/containers/SchemaTab.js @@ -5,7 +5,7 @@ import { useEffect } from "@wordpress/element"; import { __ } from "@wordpress/i18n"; import PropTypes from "prop-types"; import SchemaTab from "../components/SchemaTab"; -import SchemaFields from "../helpers/SchemaFields"; +import SchemaFields from "../helpers/fields/SchemaFields"; import withLocation from "../helpers/withLocation"; /** @@ -39,11 +39,9 @@ const getLocationBasedProps = ( location ) => { * @returns {JSX.Element} The SchemaTab. */ const SchemaTabContainer = ( props ) => { - const showArticleTypeInput = SchemaFields.articleTypeInput !== null; - useEffect( () => { props.loadSchemaPageData(); - if ( showArticleTypeInput ) { + if ( SchemaFields.showArticleInput ) { props.loadSchemaArticleData(); } }, [] ); @@ -59,7 +57,7 @@ const SchemaTabContainer = ( props ) => { "This helps search engines understand your website and your content. You can change some of your settings for this page below.", "wordpress-seo" ), - showArticleTypeInput, + showArticleTypeInput: SchemaFields.showArticleInput, pageTypeOptions, articleTypeOptions, }; diff --git a/packages/js/src/helpers/SchemaFields.js b/packages/js/src/helpers/SchemaFields.js deleted file mode 100644 index 7d853c5a2b5..00000000000 --- a/packages/js/src/helpers/SchemaFields.js +++ /dev/null @@ -1,80 +0,0 @@ -/** - * This class is responsible for handling the interaction with the hidden fields for Schema. - */ -export default class SchemaFields { - /** - * Gets the ArticleType hidden input. - * - * @returns {Object} The ArticleType input. - */ - static get articleTypeInput() { - return document.getElementById( "yoast_wpseo_schema_article_type" ); - } - - /** - * Gets the default ArticleType from the hidden input. - * - * @returns {string} The default ArticleType. - */ - static get defaultArticleType() { - return SchemaFields.articleTypeInput.getAttribute( "data-default" ); - } - - /** - * Gets the ArticleType from the hidden input. - * - * @returns {string} The ArticleType. - */ - static get articleType() { - return SchemaFields.articleTypeInput.value; - } - - /** - * Sets the ArticleType on the hidden input. - * - * @param {string} articleType The selected ArticleType. - * - * @returns {void} - */ - static set articleType( articleType ) { - SchemaFields.articleTypeInput.value = articleType; - } - - /** - * Gets the PageType hidden input. - * - * @returns {Object} The PageType input. - */ - static get pageTypeInput() { - return document.getElementById( "yoast_wpseo_schema_page_type" ); - } - - /** - * Gets the default PageType from the hidden input. - * - * @returns {string} The default PageType. - */ - static get defaultPageType() { - return SchemaFields.pageTypeInput.getAttribute( "data-default" ); - } - - /** - * Gets the PageType from the hidden input. - * - * @returns {string} The PageType. - */ - static get pageType() { - return SchemaFields.pageTypeInput.value; - } - - /** - * Sets the PageType on the hidden input. - * - * @param {string} pageType The selected PageType. - * - * @returns {void} - */ - static set pageType( pageType ) { - SchemaFields.pageTypeInput.value = pageType; - } -} diff --git a/packages/js/src/helpers/fields/AdvancedFields.js b/packages/js/src/helpers/fields/AdvancedFields.js index 0c4ad2e7a55..ca313d59ff6 100644 --- a/packages/js/src/helpers/fields/AdvancedFields.js +++ b/packages/js/src/helpers/fields/AdvancedFields.js @@ -1,5 +1,18 @@ +import { + metaKeyNoIndex, + metaKeyNoFollow, + metaKeyAdvanced, + metaKeyBcTitle, + metaKeyCanonical, +} from "../../shared-admin/constants"; +import { getMetaValue, setMetaValue } from "./rest-meta"; + /** * This class is responsible for handling the interaction with the hidden fields for Advanced Settings. + * + * When `wpseoScriptData.disableMetaboxInBlockEditor` is true the hidden DOM fields are not rendered. + * In that case getters read from the `core/editor` store and setters dispatch to it so that + * WordPress saves the values via the REST API on post save. */ export default class AdvancedFields { /** @@ -53,7 +66,7 @@ export default class AdvancedFields { * @returns {string} The No Index setting. */ static get noIndex() { - return AdvancedFields.noIndexElement && AdvancedFields.noIndexElement.value || ""; + return getMetaValue( metaKeyNoIndex, AdvancedFields.noIndexElement ) || "0"; } /** @@ -64,7 +77,7 @@ export default class AdvancedFields { * @returns {void} */ static set noIndex( value ) { - AdvancedFields.noIndexElement.value = value; + setMetaValue( metaKeyNoIndex, AdvancedFields.noIndexElement, value ); } /** @@ -73,7 +86,7 @@ export default class AdvancedFields { * @returns {string} The No Follow setting. */ static get noFollow() { - return AdvancedFields.noFollowElement && AdvancedFields.noFollowElement.value || ""; + return getMetaValue( metaKeyNoFollow, AdvancedFields.noFollowElement ) || "0"; } /** @@ -84,7 +97,7 @@ export default class AdvancedFields { * @returns {void} */ static set noFollow( value ) { - AdvancedFields.noFollowElement.value = value; + setMetaValue( metaKeyNoFollow, AdvancedFields.noFollowElement, value ); } /** @@ -93,7 +106,7 @@ export default class AdvancedFields { * @returns {string} The Advanced (metarobots) setting. */ static get advanced() { - return AdvancedFields.advancedElement && AdvancedFields.advancedElement.value || ""; + return getMetaValue( metaKeyAdvanced, AdvancedFields.advancedElement, "" ); } /** @@ -104,7 +117,7 @@ export default class AdvancedFields { * @returns {void} */ static set advanced( value ) { - AdvancedFields.advancedElement.value = value; + setMetaValue( metaKeyAdvanced, AdvancedFields.advancedElement, value ); } /** @@ -113,7 +126,7 @@ export default class AdvancedFields { * @returns {string} The BreadCrumbsTitle setting. */ static get breadcrumbsTitle() { - return AdvancedFields.breadcrumbsTitleElement && AdvancedFields.breadcrumbsTitleElement.value || ""; + return getMetaValue( metaKeyBcTitle, AdvancedFields.breadcrumbsTitleElement, "" ); } /** @@ -124,7 +137,7 @@ export default class AdvancedFields { * @returns {void} */ static set breadcrumbsTitle( value ) { - AdvancedFields.breadcrumbsTitleElement.value = value; + setMetaValue( metaKeyBcTitle, AdvancedFields.breadcrumbsTitleElement, value ); } /** @@ -133,7 +146,7 @@ export default class AdvancedFields { * @returns {string} The Canonical URL setting. */ static get canonical() { - return AdvancedFields.canonicalElement && AdvancedFields.canonicalElement.value || ""; + return getMetaValue( metaKeyCanonical, AdvancedFields.canonicalElement, "" ); } /** @@ -144,6 +157,6 @@ export default class AdvancedFields { * @returns {void} */ static set canonical( value ) { - AdvancedFields.canonicalElement.value = value; + setMetaValue( metaKeyCanonical, AdvancedFields.canonicalElement, value ); } } diff --git a/packages/js/src/helpers/fields/AnalysisFields.js b/packages/js/src/helpers/fields/AnalysisFields.js index f1fbd442c3a..fffc4aa8166 100644 --- a/packages/js/src/helpers/fields/AnalysisFields.js +++ b/packages/js/src/helpers/fields/AnalysisFields.js @@ -1,5 +1,131 @@ +import { dispatch, select, subscribe } from "@wordpress/data"; +import { + metaKeyFocusKw, + metaKeyIsCornerstone, + metaKeyLinkdex, + metaKeyContentScore, + metaKeyInclusiveLanguageScore, +} from "../../shared-admin/constants"; +import { isRestMetaActive, shouldSkipMetaWrite, writeMetaWithoutUndo, getMetaValue } from "./rest-meta"; + +/** + * Returns whether the core/editor store has finished loading the post type config. + * Dispatching editPost before the entity config is available throws a runtime error. + * + * @returns {boolean} True when the post type config is loaded and editPost can be called safely. + */ +function isEditorReady() { + return Boolean( select( "core/editor" ).getCurrentPostType() ); +} + +// Pending meta writes buffered before the editor entity config is ready. +// Each entry is { value: string, undoIgnore: boolean } so the undo-ignore flag survives queuing. +// Keyed by meta key so that rapid successive writes for the same key collapse to the last value. +const pendingWrites = new Map(); +let unsubscribeFlush = null; + +/** + * Drains pendingWrites, routing undoIgnore entries through writeMetaWithoutUndo and the rest + * through editPost so that analysis scores never land on the undo stack regardless of when they + * were queued. + * + * @returns {void} + */ +function flushPendingWrites() { + if ( pendingWrites.size === 0 ) { + return; + } + const scoreMeta = {}; + const editMeta = {}; + for ( const [ key, { value, undoIgnore } ] of pendingWrites ) { + if ( undoIgnore ) { + scoreMeta[ key ] = value; + } else { + editMeta[ key ] = value; + } + } + pendingWrites.clear(); + if ( Object.keys( scoreMeta ).length > 0 ) { + writeMetaWithoutUndo( scoreMeta ); + } + if ( Object.keys( editMeta ).length > 0 ) { + dispatch( "core/editor" ).editPost( { meta: editMeta } ); + } +} + +/** + * Subscribes to core/editor store changes and flushes pending meta writes as soon as the editor + * is ready. Unsubscribes immediately after the first successful flush to avoid ongoing overhead. + * + * @returns {void} + */ +function scheduleFlush() { + if ( unsubscribeFlush ) { + return; + } + unsubscribeFlush = subscribe( () => { + if ( ! isEditorReady() ) { + return; + } + unsubscribeFlush(); + unsubscribeFlush = null; + flushPendingWrites(); + }, "core/editor" ); +} + +/** + * Dispatches a meta write immediately if the editor is ready, or queues it for the next flush. + * When the editor is already ready any previously queued writes are flushed together with this + * one to minimise unnecessary state updates. + * + * @param {string} metaKey The meta key to write. + * @param {string} value The value to write. + * @param {boolean} undoIgnore When true, the write bypasses the undo stack via editEntityRecord. + * Use for computed values (e.g. analysis scores) that should not + * be undoable. + * + * @returns {void} + */ +function writeOrQueue( metaKey, value, undoIgnore = false ) { + // All meta fields are registered with type: string. Coerce here so callers that pass + // numeric scores (e.g. linkdex) don't trigger a REST API type-validation error. + pendingWrites.set( metaKey, { value: String( value ), undoIgnore } ); + if ( ! isEditorReady() ) { + scheduleFlush(); + return; + } + // Cancel any pending flush subscription — we are dispatching everything now. + if ( unsubscribeFlush ) { + unsubscribeFlush(); + unsubscribeFlush = null; + } + flushPendingWrites(); +} + +/** + * Sets a meta value either by writing directly to the DOM element when REST meta is inactive, or via the REST API when active. + * + * @param {string} metaKey The meta key to write. + * @param {HTMLElement|null} element The DOM element to write to when REST meta is inactive. + * @param {string} value The value to write. + * @returns {void} + */ +const setScoreMeta = ( metaKey, element, value ) => { + if ( isRestMetaActive() ) { + writeOrQueue( metaKey, value, true ); + return; + } + if ( element ) { + element.value = value; + } +}; + /** * This class is responsible for handling the interaction with the hidden fields for the analysis. + * + * When `wpseoScriptData.disableMetaboxInBlockEditor` is true the hidden DOM fields are not rendered. + * In that case getters read from the `core/editor` store and setters dispatch to it so that + * WordPress saves the values via the REST API on post save. */ export default class AnalysisFields { /** @@ -55,6 +181,12 @@ export default class AnalysisFields { * @returns {void} */ static set keyphrase( value ) { + if ( isRestMetaActive() ) { + if ( ! shouldSkipMetaWrite( metaKeyFocusKw, value ) ) { + writeOrQueue( metaKeyFocusKw, value ); + } + return; + } if ( AnalysisFields.keyphraseElement ) { AnalysisFields.keyphraseElement.value = value; } @@ -66,7 +198,7 @@ export default class AnalysisFields { * @returns {string} The keyphrase. */ static get keyphrase() { - return AnalysisFields.keyphraseElement?.value ?? ""; + return getMetaValue( metaKeyFocusKw, AnalysisFields.keyphraseElement, "" ); } /** @@ -77,6 +209,13 @@ export default class AnalysisFields { * @returns {void} */ static set isCornerstone( value ) { + if ( isRestMetaActive() ) { + const newValue = value ? "1" : "0"; + if ( ! shouldSkipMetaWrite( metaKeyIsCornerstone, newValue ) ) { + writeOrQueue( metaKeyIsCornerstone, newValue ); + } + return; + } if ( AnalysisFields.isCornerstoneElement ) { AnalysisFields.isCornerstoneElement.value = value ? "1" : "0"; } @@ -88,7 +227,7 @@ export default class AnalysisFields { * @returns {boolean} The isCornerstone. */ static get isCornerstone() { - return AnalysisFields.isCornerstoneElement?.value === "1"; + return getMetaValue( metaKeyIsCornerstone, AnalysisFields.isCornerstoneElement, "0" ) === "1"; } /** @@ -99,9 +238,7 @@ export default class AnalysisFields { * @returns {void} */ static set seoScore( value ) { - if ( AnalysisFields.seoScoreElement ) { - AnalysisFields.seoScoreElement.value = value; - } + setScoreMeta( metaKeyLinkdex, AnalysisFields.seoScoreElement, value ); } /** @@ -110,7 +247,7 @@ export default class AnalysisFields { * @returns {string} The SEO (overall) score. */ static get seoScore() { - return AnalysisFields.seoScoreElement?.value ?? ""; + return getMetaValue( metaKeyLinkdex, AnalysisFields.seoScoreElement, "" ); } /** @@ -121,9 +258,7 @@ export default class AnalysisFields { * @returns {void} */ static set readabilityScore( value ) { - if ( AnalysisFields.readabilityScoreElement ) { - AnalysisFields.readabilityScoreElement.value = value; - } + setScoreMeta( metaKeyContentScore, AnalysisFields.readabilityScoreElement, value ); } /** @@ -132,7 +267,7 @@ export default class AnalysisFields { * @returns {string} The Readability (overall) score. */ static get readabilityScore() { - return AnalysisFields.readabilityScoreElement?.value ?? ""; + return getMetaValue( metaKeyContentScore, AnalysisFields.readabilityScoreElement, "" ); } /** @@ -143,9 +278,7 @@ export default class AnalysisFields { * @returns {void} */ static set inclusiveLanguageScore( value ) { - if ( AnalysisFields.inclusiveLanguageScoreElement ) { - AnalysisFields.inclusiveLanguageScoreElement.value = value; - } + setScoreMeta( metaKeyInclusiveLanguageScore, AnalysisFields.inclusiveLanguageScoreElement, value ); } /** @@ -154,6 +287,6 @@ export default class AnalysisFields { * @returns {string} The inclusive language (overall) score. */ static get inclusiveLanguageScore() { - return AnalysisFields.inclusiveLanguageScoreElement?.value ?? ""; + return getMetaValue( metaKeyInclusiveLanguageScore, AnalysisFields.inclusiveLanguageScoreElement, "" ); } } diff --git a/packages/js/src/helpers/fields/EstimatedReadingTimeFields.js b/packages/js/src/helpers/fields/EstimatedReadingTimeFields.js index 01264eb6e35..f1f3cef2387 100644 --- a/packages/js/src/helpers/fields/EstimatedReadingTimeFields.js +++ b/packages/js/src/helpers/fields/EstimatedReadingTimeFields.js @@ -1,5 +1,12 @@ +import { metaKeyEstimatedReadingTime } from "../../shared-admin/constants"; +import { getMetaValue, setMetaValue } from "./rest-meta"; + /** * This class is responsible for handling the interaction with the hidden fields for Estimated Reading Time (ert). + * + * When `wpseoScriptData.disableMetaboxInBlockEditor` is true the hidden DOM field is not rendered. + * In that case the getter reads from the `core/editor` store and the setter dispatches to it so that + * WordPress saves the value via the REST API on post save. */ export default class EstimatedReadingTimeFields { /** @@ -17,8 +24,7 @@ export default class EstimatedReadingTimeFields { * @returns {string} The estimated reading time. */ static get estimatedReadingTime() { - return EstimatedReadingTimeFields.estimatedReadingTimeElement && - EstimatedReadingTimeFields.estimatedReadingTimeElement.value || ""; + return getMetaValue( metaKeyEstimatedReadingTime, EstimatedReadingTimeFields.estimatedReadingTimeElement, "" ); } /** @@ -29,8 +35,6 @@ export default class EstimatedReadingTimeFields { * @returns {void} */ static set estimatedReadingTime( value ) { - if ( EstimatedReadingTimeFields.estimatedReadingTimeElement ) { - EstimatedReadingTimeFields.estimatedReadingTimeElement.value = value; - } + setMetaValue( metaKeyEstimatedReadingTime, EstimatedReadingTimeFields.estimatedReadingTimeElement, value, true ); } } diff --git a/packages/js/src/helpers/fields/FacebookFields.js b/packages/js/src/helpers/fields/FacebookFields.js index 02f12321154..6dc70c07420 100644 --- a/packages/js/src/helpers/fields/FacebookFields.js +++ b/packages/js/src/helpers/fields/FacebookFields.js @@ -1,5 +1,17 @@ +import { + metaKeyOgTitle, + metaKeyOgDescription, + metaKeyOgImageId, + metaKeyOgImage, +} from "../../shared-admin/constants"; +import { getMetaValue, setMetaValue } from "./rest-meta"; + /** * This class is responsible for handling the interaction with the hidden fields for Facebook. + * + * When `wpseoScriptData.disableMetaboxInBlockEditor` is true the hidden DOM fields are not rendered. + * In that case getters read from the `core/editor` store and setters dispatch to it so that + * WordPress saves the values via the REST API on post save. */ export default class FacebookFields { /** @@ -44,7 +56,7 @@ export default class FacebookFields { * @returns {string} The Facebook title. */ static get title() { - return FacebookFields.titleElement.value; + return getMetaValue( metaKeyOgTitle, FacebookFields.titleElement, "" ); } /** @@ -55,7 +67,7 @@ export default class FacebookFields { * @returns {void} */ static set title( value ) { - FacebookFields.titleElement.value = value; + setMetaValue( metaKeyOgTitle, FacebookFields.titleElement, value ); } /** @@ -66,7 +78,7 @@ export default class FacebookFields { * @returns {void} */ static set description( value ) { - FacebookFields.descriptionElement.value = value; + setMetaValue( metaKeyOgDescription, FacebookFields.descriptionElement, value ); } /** @@ -75,7 +87,7 @@ export default class FacebookFields { * @returns {string} The Facebook description. */ static get description() { - return FacebookFields.descriptionElement.value; + return getMetaValue( metaKeyOgDescription, FacebookFields.descriptionElement, "" ); } /** @@ -86,7 +98,7 @@ export default class FacebookFields { * @returns {void} */ static set imageId( value ) { - FacebookFields.imageIdElement.value = value; + setMetaValue( metaKeyOgImageId, FacebookFields.imageIdElement, value ); } /** @@ -95,7 +107,7 @@ export default class FacebookFields { * @returns {string} The Facebook imageId. */ static get imageId() { - return FacebookFields.imageIdElement.value; + return getMetaValue( metaKeyOgImageId, FacebookFields.imageIdElement, "" ); } /** @@ -106,7 +118,7 @@ export default class FacebookFields { * @returns {void} */ static set imageUrl( value ) { - FacebookFields.imageUrlElement.value = value; + setMetaValue( metaKeyOgImage, FacebookFields.imageUrlElement, value ); } /** @@ -115,6 +127,6 @@ export default class FacebookFields { * @returns {string} The Facebook imageUrl. */ static get imageUrl() { - return FacebookFields.imageUrlElement.value; + return getMetaValue( metaKeyOgImage, FacebookFields.imageUrlElement, "" ); } } diff --git a/packages/js/src/helpers/fields/PrimaryTermFields.js b/packages/js/src/helpers/fields/PrimaryTermFields.js new file mode 100644 index 00000000000..d7f39b4144e --- /dev/null +++ b/packages/js/src/helpers/fields/PrimaryTermFields.js @@ -0,0 +1,64 @@ +import { getMetaValue, setMetaValue } from "./rest-meta"; + +/** + * Returns the REST meta key for the given taxonomy. + * + * @param {string} taxonomyName The taxonomy name. + * + * @returns {string} The meta key. + */ +function metaKey( taxonomyName ) { + return `_yoast_wpseo_primary_${ taxonomyName }`; +} + +/** + * This class is responsible for handling the interaction with primary term hidden fields. + * + * Unlike the other Fields helpers the meta key is dynamic (one per taxonomy), so methods + * accept the taxonomy name rather than operating on fixed constants. + * + * When `wpseoScriptData.disableMetaboxInBlockEditor` is true the hidden DOM fields are not + * rendered. In that case getters read from the `core/editor` store and setters dispatch to it + * so that WordPress saves the values via the REST API on post save. + */ +export default class PrimaryTermFields { + /** + * Returns the DOM element for the primary term hidden input. + * + * @param {string} fieldId The element ID to look up. + * + * @returns {HTMLElement|null} The element, or null when not rendered. + */ + static getPrimaryTermElement( fieldId ) { + return document.getElementById( fieldId ); + } + + /** + * Gets the current primary term ID for the given taxonomy. + * + * @param {string} taxonomyName The taxonomy name. + * @param {HTMLElement|null} inputElement The hidden input element, or null if not rendered. + * + * @returns {string} The primary term ID as a string, or an empty string when unset. + */ + static get( taxonomyName, fieldId ) { + return getMetaValue( metaKey( taxonomyName ), PrimaryTermFields.getPrimaryTermElement( fieldId ), "" ); + } + + /** + * Sets the primary term ID for the given taxonomy. + * + * Writes to the hidden DOM input in classic-editor or metabox-enabled block-editor mode. + * Dispatches an editPost action in REST-first block-editor mode. Pass -1 to clear the value. + * + * @param {string} taxonomyName The taxonomy name. + * @param {number} termId The term ID. Pass -1 to clear. + * @param {string} fieldId The field ID of the hidden input element. + * + * @returns {void} + */ + static set( taxonomyName, fieldId, termId ) { + const value = termId === -1 ? "" : String( termId ); + setMetaValue( metaKey( taxonomyName ), PrimaryTermFields.getPrimaryTermElement( fieldId ), value ); + } +} diff --git a/packages/js/src/helpers/fields/SchemaFields.js b/packages/js/src/helpers/fields/SchemaFields.js new file mode 100644 index 00000000000..beb5d9eb37f --- /dev/null +++ b/packages/js/src/helpers/fields/SchemaFields.js @@ -0,0 +1,97 @@ +import { metaKeySchemaArticleType, metaKeySchemaPageType } from "../../shared-admin/constants/meta-keys"; +import { getMetaValue, setMetaValue } from "./rest-meta"; +import { get } from "lodash"; + +/** + * This class is responsible for handling the interaction with the hidden fields for Schema. + * + * When `wpseoScriptData.disableMetaboxInBlockEditor` is true the hidden DOM fields are not rendered. + * In that case getters read from the `core/editor` store and setters dispatch to it so that + * WordPress saves the values via the REST API on post save. + */ +export default class SchemaFields { + /** + * Gets the ArticleType hidden input. + * + * @returns {Object} The ArticleType input. + */ + static get articleTypeInput() { + return document.getElementById( "yoast_wpseo_schema_article_type" ); + } + + /** + * Gets the default ArticleType from the hidden input. + * + * @returns {string} The default ArticleType. + */ + static get defaultArticleType() { + return get( window, "wpseoScriptData.metabox.schema.defaultArticleType", "" ); + } + + /** + * Gets the ArticleType from the hidden input or the REST meta store. + * + * @returns {string} The ArticleType. + */ + static get articleType() { + return getMetaValue( metaKeySchemaArticleType, SchemaFields.articleTypeInput, "" ); + } + + /** + * Sets the ArticleType on the hidden input or dispatches to the REST meta store. + * + * @param {string} articleType The selected ArticleType. + * + * @returns {void} + */ + static set articleType( articleType ) { + setMetaValue( metaKeySchemaArticleType, SchemaFields.articleTypeInput, articleType ); + } + + /** + * Gets the PageType hidden input. + * + * @returns {Object} The PageType input. + */ + static get pageTypeInput() { + return document.getElementById( "yoast_wpseo_schema_page_type" ); + } + + /** + * Gets the default PageType from the hidden input. + * + * @returns {string} The default PageType. + */ + static get defaultPageType() { + return get( window, "wpseoScriptData.metabox.schema.defaultPageType", "" ); + } + + /** + * Gets the PageType from the hidden input or the REST meta store. + * + * @returns {string} The PageType. + */ + static get pageType() { + return getMetaValue( metaKeySchemaPageType, SchemaFields.pageTypeInput, "" ); + } + + /** + * Sets the PageType on the hidden input or dispatches to the REST meta store. + * + * @param {string} pageType The selected PageType. + * + * @returns {void} + */ + static set pageType( pageType ) { + setMetaValue( metaKeySchemaPageType, SchemaFields.pageTypeInput, pageType ); + } + + /** + * Should show the article input. + * + * @returns {boolean} True if the article input should be shown, false otherwise. + */ + static get showArticleInput() { + return Boolean( get( window, "wpseoScriptData.metabox.schema.showArticleInput", false ) ); + } +} diff --git a/packages/js/src/helpers/fields/SearchMetadataFields.js b/packages/js/src/helpers/fields/SearchMetadataFields.js index 68b4deb18dd..2a10a1fbe17 100644 --- a/packages/js/src/helpers/fields/SearchMetadataFields.js +++ b/packages/js/src/helpers/fields/SearchMetadataFields.js @@ -1,5 +1,12 @@ +import { metaKeyTitle, metaKeyMetaDesc } from "../../shared-admin/constants"; +import { getMetaValue, setMetaValue } from "./rest-meta"; + /** * This class is responsible for handling the interaction with the hidden fields for the search metadata. + * + * When `wpseoScriptData.disableMetaboxInBlockEditor` is true the hidden DOM fields are not rendered. + * In that case getters read from the `core/editor` store and setters dispatch to it so that + * WordPress saves the values via the REST API on post save. */ export default class SearchMetadataFields { /** @@ -35,7 +42,7 @@ export default class SearchMetadataFields { * @returns {string} The title. */ static get title() { - return SearchMetadataFields.titleElement.value; + return getMetaValue( metaKeyTitle, SearchMetadataFields.titleElement, "" ); } /** @@ -46,7 +53,7 @@ export default class SearchMetadataFields { * @returns {void} */ static set title( value ) { - SearchMetadataFields.titleElement.value = value; + setMetaValue( metaKeyTitle, SearchMetadataFields.titleElement, value ); } /** @@ -55,7 +62,7 @@ export default class SearchMetadataFields { * @returns {string} The description. */ static get description() { - return SearchMetadataFields.descriptionElement.value; + return getMetaValue( metaKeyMetaDesc, SearchMetadataFields.descriptionElement, "" ); } /** @@ -66,7 +73,7 @@ export default class SearchMetadataFields { * @returns {void} */ static set description( value ) { - SearchMetadataFields.descriptionElement.value = value; + setMetaValue( metaKeyMetaDesc, SearchMetadataFields.descriptionElement, value ); } /** diff --git a/packages/js/src/helpers/fields/TwitterFields.js b/packages/js/src/helpers/fields/TwitterFields.js index 609b8b02500..bd6fa1d86c9 100644 --- a/packages/js/src/helpers/fields/TwitterFields.js +++ b/packages/js/src/helpers/fields/TwitterFields.js @@ -1,5 +1,17 @@ +import { + metaKeyTwitterTitle, + metaKeyTwitterDescription, + metaKeyTwitterImageId, + metaKeyTwitterImage, +} from "../../shared-admin/constants"; +import { getMetaValue, setMetaValue } from "./rest-meta"; + /** * This class is responsible for handling the interaction with the hidden fields for Twitter. + * + * When `wpseoScriptData.disableMetaboxInBlockEditor` is true the hidden DOM fields are not rendered. + * In that case getters read from the `core/editor` store and setters dispatch to it so that + * WordPress saves the values via the REST API on post save. */ export default class TwitterFields { /** @@ -44,7 +56,7 @@ export default class TwitterFields { * @returns {string} The Twitter title. */ static get title() { - return TwitterFields.titleElement.value; + return getMetaValue( metaKeyTwitterTitle, TwitterFields.titleElement, "" ); } /** @@ -55,7 +67,7 @@ export default class TwitterFields { * @returns {void} */ static set title( value ) { - TwitterFields.titleElement.value = value; + setMetaValue( metaKeyTwitterTitle, TwitterFields.titleElement, value ); } /** @@ -66,7 +78,7 @@ export default class TwitterFields { * @returns {void} */ static set description( value ) { - TwitterFields.descriptionElement.value = value; + setMetaValue( metaKeyTwitterDescription, TwitterFields.descriptionElement, value ); } /** @@ -75,7 +87,7 @@ export default class TwitterFields { * @returns {string} The Twitter description. */ static get description() { - return TwitterFields.descriptionElement.value; + return getMetaValue( metaKeyTwitterDescription, TwitterFields.descriptionElement, "" ); } /** @@ -86,7 +98,7 @@ export default class TwitterFields { * @returns {void} */ static set imageId( value ) { - TwitterFields.imageIdElement.value = value; + setMetaValue( metaKeyTwitterImageId, TwitterFields.imageIdElement, value ); } /** @@ -95,7 +107,7 @@ export default class TwitterFields { * @returns {string} The Twitter imageId. */ static get imageId() { - return TwitterFields.imageIdElement.value; + return getMetaValue( metaKeyTwitterImageId, TwitterFields.imageIdElement, "" ); } /** @@ -106,7 +118,7 @@ export default class TwitterFields { * @returns {void} */ static set imageUrl( value ) { - TwitterFields.imageUrlElement.value = value; + setMetaValue( metaKeyTwitterImage, TwitterFields.imageUrlElement, value ); } /** @@ -115,6 +127,6 @@ export default class TwitterFields { * @returns {string} The Twitter imageUrl. */ static get imageUrl() { - return TwitterFields.imageUrlElement.value; + return getMetaValue( metaKeyTwitterImage, TwitterFields.imageUrlElement, "" ); } } diff --git a/packages/js/src/helpers/fields/rest-meta.js b/packages/js/src/helpers/fields/rest-meta.js new file mode 100644 index 00000000000..96a7abbb411 --- /dev/null +++ b/packages/js/src/helpers/fields/rest-meta.js @@ -0,0 +1,117 @@ +import { get } from "lodash"; +import { dispatch, select } from "@wordpress/data"; + +/** + * Returns whether the block-editor REST meta path is active (metabox hidden fields disabled). + * + * When `wpseoScriptData.disableMetaboxInBlockEditor` is true, the PHP metabox and its hidden + * input fields are not rendered. All Fields helpers use this flag to decide whether to read + * from / write to the DOM or to the `core/editor` store instead. + * @returns {boolean} True when REST meta is active. + */ +export const isRestMetaActive = () => Boolean( get( window, "wpseoScriptData.disableMetaboxInBlockEditor", false ) ); + +/** + * Returns whether a meta write to core/editor should be skipped. + * + * Returns true in two cases: + * 1. The entity meta hasn't loaded yet (getEditedPostAttribute("meta") is null). + * Writing before the entity loads would dispatch editPost with an incorrect value, + * which then overrides the actual saved value once the entity finishes loading. + * 2. The new value already matches the current edited value. + * Skipping no-op dispatches avoids marking the post dirty unnecessarily. + * + * @param {string} metaKey The meta key to check. + * @param {string} newValue The value about to be written. + * + * @returns {boolean} True when the write should be skipped. + */ +export const shouldSkipMetaWrite = ( metaKey, newValue ) => { + const currentMeta = select( "core/editor" ).getEditedPostAttribute( "meta" ); + return ! currentMeta || currentMeta[ metaKey ] === String( newValue ); +}; + +/** + * Writes one or more meta values to the core/editor store without adding an undo entry. + * + * Use this for computed/derived values (e.g. analysis scores, estimated reading time) that + * should be persisted on save but must not pollute the block-editor undo stack. + * + * @param {Object} meta A plain object mapping meta keys to their new values. + * + * @returns {void} + */ +export const writeMetaWithoutUndo = ( meta ) => { + const postType = select( "core/editor" ).getCurrentPostType(); + const postId = select( "core/editor" ).getCurrentPostId(); + dispatch( "core" ).editEntityRecord( "postType", postType, postId, { meta }, { undoIgnore: true } ); +}; + +/** + * Reads a single meta value from the core/editor store. + * + * @param {string} metaKey The meta key. + * @param {string} fallback Returned when the key is absent or null. Defaults to "". + * + * @returns {string} The meta value, or fallback. + */ +const readMeta = ( metaKey, fallback = "" ) => + select( "core/editor" ).getEditedPostAttribute( "meta" )?.[ metaKey ] ?? fallback; + +/** + * Get value from the DOM when REST meta is inactive, or from the core/editor store when active. + * + * @param {string} metaKey The meta key. + * @param {HTMLElement} element The DOM element to read from when REST meta is inactive. + * @param {string} fallback Returned when the key is absent or null. Defaults to "". + * + * @returns {string} The meta value, or fallback. + */ +export const getMetaValue = ( metaKey, element, fallback = "" ) => { + if ( isRestMetaActive() ) { + return readMeta( metaKey, fallback ); + } + return element?.value ?? fallback; +}; + +/** + * Writes a single meta value to the core/editor store. + * + * Always coerces value to a string — all Yoast meta fields are registered with type:string + * and the REST API rejects non-string values with a 400 error. + * + * @param {string} metaKey The meta key. + * @param {*} value The value to write. Coerced to string before dispatch. + * + * @returns {void} + */ +const writeMeta = ( metaKey, value ) => { + const stringValue = String( value ); + if ( ! shouldSkipMetaWrite( metaKey, stringValue ) ) { + dispatch( "core/editor" ).editPost( { meta: { [ metaKey ]: stringValue } } ); + } +}; + +/** + * Sets Meta value on the DOM when REST meta is inactive, or dispatches to core/editor when active. + * + * @param {string} metaKey The meta key. + * @param {HTMLElement} element The DOM element to write to when REST meta is inactive. + * @param {string} value The value to write. + * @param {boolean} withoutUndo When true, the write bypasses the undo stack. Defaults to false. + * + * @returns {void} + */ +export const setMetaValue = ( metaKey, element, value, withoutUndo = false ) => { + if ( isRestMetaActive() ) { + if ( withoutUndo ) { + writeMetaWithoutUndo( { [ metaKey ]: value } ); + } else { + writeMeta( metaKey, value ); + } + return; + } + if ( element ) { + element.value = value; + } +}; diff --git a/packages/js/src/initializers/post-scraper.js b/packages/js/src/initializers/post-scraper.js index 8be15058b71..dbffb0ec059 100644 --- a/packages/js/src/initializers/post-scraper.js +++ b/packages/js/src/initializers/post-scraper.js @@ -1,3 +1,4 @@ +/* eslint-disable complexity */ /* global wpseoScriptData */ // External dependencies. @@ -48,6 +49,8 @@ import { actions } from "@yoast/externals/redux"; // Helper dependencies. import isBlockEditor from "../helpers/isBlockEditor"; +import AnalysisFields from "../helpers/fields/AnalysisFields"; +import { isRestMetaActive } from "../helpers/fields/rest-meta"; const { setFocusKeyword, @@ -131,7 +134,7 @@ export default function initPostScraper( $, store, editorData ) { * @returns {void} */ function initializeKeywordAnalysis( activePublishBox ) { - const savedKeywordScore = $( "#yoast_wpseo_linkdex" ).val(); + const savedKeywordScore = AnalysisFields.seoScore; const indicator = getIndicatorForScore( savedKeywordScore ); @@ -149,7 +152,7 @@ export default function initPostScraper( $, store, editorData ) { * @returns {void} */ function initializeContentAnalysis( activePublishBox ) { - const savedContentScore = $( "#yoast_wpseo_content_score" ).val(); + const savedContentScore = AnalysisFields.readabilityScore; const indicator = getIndicatorForScore( savedContentScore ); @@ -166,7 +169,7 @@ export default function initPostScraper( $, store, editorData ) { * @returns {void} */ function initializeInclusiveLanguageAnalysis( activePublishBox ) { - const savedContentScore = $( "#yoast_wpseo_inclusive_language_score" ).val(); + const savedContentScore = AnalysisFields.inclusiveLanguageScore; const indicator = getIndicatorForScore( savedContentScore ); @@ -202,7 +205,7 @@ export default function initPostScraper( $, store, editorData ) { * @returns {PostDataCollector} The initialized post data collector. */ function initializePostDataCollector( data ) { - const postDataCollector = new PostDataCollector( { + const collector = new PostDataCollector( { data, store: store, } ); @@ -215,19 +218,19 @@ export default function initPostScraper( $, store, editorData ) { * * See bind event on "ajaxComplete" in this file. */ - postDataCollector.leavePostNameUntouched = false; + collector.leavePostNameUntouched = false; - return postDataCollector; + return collector; } /** * Returns the arguments necessary to initialize the app. * - * @param {Object} store The store. + * @param {Object} yoastStore The store. * * @returns {Object} The arguments to initialize the app */ - function getAppArgs( store ) { + function getAppArgs( yoastStore ) { const args = { // ID's of elements that need to trigger updating the analyzer. elementTarget: [ @@ -243,7 +246,7 @@ export default function initPostScraper( $, store, editorData ) { getData: postDataCollector.getData.bind( postDataCollector ), }, locale: wpseoScriptData.metabox.contentLocale, - marker: getApplyMarks( store ), + marker: getApplyMarks( yoastStore ), contentAnalysisActive: isContentAnalysisActive(), keywordAnalysisActive: isKeywordAnalysisActive(), debouncedRefresh: false, @@ -252,22 +255,27 @@ export default function initPostScraper( $, store, editorData ) { }; if ( isKeywordAnalysisActive() ) { - store.dispatch( setFocusKeyword( $( "#yoast_wpseo_focuskw" ).val() ) ); + // In REST meta mode, when the entity meta is not yet available, the initial + // keyphrase dispatch is deferred together with title, description, and cornerstone + // in the single meta-ready subscriber inside initializeSnippetEditorSync. + if ( ! isRestMetaActive() || select( "core/editor" ).getEditedPostAttribute( "meta" ) ) { + yoastStore.dispatch( setFocusKeyword( AnalysisFields.keyphrase ) ); + } args.callbacks.saveScores = postDataCollector.saveScores.bind( postDataCollector ); args.callbacks.updatedKeywordsResults = function( results ) { - const keyword = store.getState().focusKeyword; + const keyword = yoastStore.getState().focusKeyword; - store.dispatch( setSeoResultsForKeyword( keyword, results ) ); - store.dispatch( refreshSnippetEditor() ); + yoastStore.dispatch( setSeoResultsForKeyword( keyword, results ) ); + yoastStore.dispatch( refreshSnippetEditor() ); }; } if ( isContentAnalysisActive() ) { args.callbacks.saveContentScore = postDataCollector.saveContentScore.bind( postDataCollector ); args.callbacks.updatedContentResults = function( results ) { - store.dispatch( setReadabilityResults( results ) ); - store.dispatch( refreshSnippetEditor() ); + yoastStore.dispatch( setReadabilityResults( results ) ); + yoastStore.dispatch( refreshSnippetEditor() ); }; } @@ -317,14 +325,14 @@ export default function initPostScraper( $, store, editorData ) { /** * Rerun the analysis when the title or meta description in the snippet changes. * - * @param {Object} store The store. + * @param {Object} yoastStore The store. * @param {Function} _refreshAnalysis Function that triggers a refresh of the analysis. * * @returns {void} */ - function handleStoreChange( store, _refreshAnalysis ) { + function handleStoreChange( yoastStore, _refreshAnalysis ) { const previousAnalysisData = currentAnalysisData || ""; - currentAnalysisData = store.getState().analysisData.snippet; + currentAnalysisData = yoastStore.getState().analysisData.snippet; const isDirty = ! isShallowEqualObjects( previousAnalysisData, currentAnalysisData ); if ( isDirty ) { @@ -368,11 +376,46 @@ export default function initPostScraper( $, store, editorData ) { return select( "core/edit-post" ).getEditorMode(); } + /** + * Dispatches the initial cornerstone, keyphrase, title, and description to the store. + * In REST meta mode, defers the dispatch until core/editor has loaded the entity meta. + * + * @param {Object} snippetEditorTemplates The snippet editor templates from l10n. + * + * @returns {void} + */ + function dispatchInitialMetaOnReady( snippetEditorTemplates ) { + if ( isRestMetaActive() && ! select( "core/editor" ).getEditedPostAttribute( "meta" ) ) { + // In REST meta mode the entity meta hasn't loaded yet, so title, description, + // keyphrase, and cornerstone all return empty/false values. A single subscriber + // re-dispatches all four once core/editor makes the meta available. + const unsubscribeMetaReady = subscribe( () => { + if ( ! select( "core/editor" ).getEditedPostAttribute( "meta" ) ) { + return; + } + unsubscribeMetaReady(); + const freshData = getDataFromCollector( postDataCollector ); + const freshDataWithTemplates = getDataWithTemplates( freshData, snippetEditorTemplates ); + store.dispatch( updateData( { + title: freshDataWithTemplates.title, + description: freshDataWithTemplates.description, + } ) ); + store.dispatch( setCornerstoneContent( AnalysisFields.isCornerstone ) ); + if ( isKeywordAnalysisActive() ) { + store.dispatch( setFocusKeyword( AnalysisFields.keyphrase ) ); + } + }, "core/editor" ); + } else { + store.dispatch( setCornerstoneContent( AnalysisFields.isCornerstone ) ); + } + } + /** * Initializes analysis for the post edit screen. * * @returns {void} */ + // eslint-disable-next-line max-statements function initializePostAnalysis() { metaboxContainer = $( "#wpseo_meta" ); @@ -381,8 +424,10 @@ export default function initPostScraper( $, store, editorData ) { handlePageBuilderCompatibility(); - // Avoid error when snippet metabox is not rendered. - if ( metaboxContainer.length === 0 ) { + // Avoid error when snippet metabox is not rendered, unless the metabox has been intentionally + // disabled in the block editor (REST-first mode), in which case the app still needs to initialize + // so that window.YoastSEO.app and its Pluggable hooks are available for third-party integrations. + if ( metaboxContainer.length === 0 && ! isRestMetaActive() ) { return; } @@ -390,9 +435,12 @@ export default function initPostScraper( $, store, editorData ) { publishBox.initialize(); const appArgs = getAppArgs( store ); + + // Sets up the window.YoastSEO namespace, creates the App instance, and wires all + // app-level overwrites (Pluggable, refresh, analysis worker, etc.). app = new App( appArgs ); - // Content analysis + // Content analysis. window.YoastSEO = window.YoastSEO || {}; window.YoastSEO.app = app; window.YoastSEO.store = store; @@ -439,7 +487,7 @@ export default function initPostScraper( $, store, editorData ) { // Backwards compatibility. window.YoastSEO.analyzerArgs = appArgs; - // Analysis plugins + // Analysis plugins. window.YoastSEO.wp = {}; window.YoastSEO.wp.replaceVarsPlugin = new YoastReplaceVarPlugin( app, store ); initShortcodePlugin( app, store ); @@ -463,7 +511,6 @@ export default function initPostScraper( $, store, editorData ) { } ) .catch( handleWorkerError ); - postDataCollector.bindElementEvents( debounce( () => refreshAnalysis( window.YoastSEO.analysis.worker, window.YoastSEO.analysis.collectData, @@ -485,17 +532,15 @@ export default function initPostScraper( $, store, editorData ) { editorData.setRefresh( app.refresh ); } - // Initialize the snippet editor data. + // Initialize snippet editor data and wire the store subscriber that keeps it in sync. let snippetEditorData = getDataFromCollector( postDataCollector ); const snippetEditorTemplates = getTemplatesFromL10n( wpseoScriptData.metabox ); snippetEditorData = getDataWithTemplates( snippetEditorData, snippetEditorTemplates ); - // Set the initial snippet editor data. store.dispatch( updateData( snippetEditorData ) ); - // This used to be a checkbox, then became a hidden input. For consistency, we set the value to '1'. - store.dispatch( setCornerstoneContent( document.getElementById( "yoast_wpseo_is_cornerstone" ).value === "1" ) ); - // Save the keyword, in order to compare it to store changes. + dispatchInitialMetaOnReady( snippetEditorTemplates ); + let focusKeyword = store.getState().focusKeyword; requestWordsToHighlight( window.YoastSEO.analysis.worker.runResearch, store, focusKeyword ); const refreshAfterFocusKeywordChange = debounce( () => { @@ -511,14 +556,13 @@ export default function initPostScraper( $, store, editorData ) { focusKeyword = newFocusKeyword; requestWordsToHighlight( window.YoastSEO.analysis.worker.runResearch, store, focusKeyword ); - $( "#yoast_wpseo_focuskw" ).val( focusKeyword ); + AnalysisFields.keyphrase = focusKeyword; refreshAfterFocusKeywordChange(); } const data = getDataFromStore( store ); const dataWithoutTemplates = getDataWithoutTemplates( data, snippetEditorTemplates ); - if ( snippetEditorData.title !== data.title ) { postDataCollector.setDataFromSnippet( dataWithoutTemplates.title, "snippet_title" ); } @@ -535,7 +579,7 @@ export default function initPostScraper( $, store, editorData ) { if ( previousCornerstoneValue !== currentState.isCornerstone ) { previousCornerstoneValue = currentState.isCornerstone; - document.getElementById( "yoast_wpseo_is_cornerstone" ).value = currentState.isCornerstone; + AnalysisFields.isCornerstone = currentState.isCornerstone; app.changeAssessorOptions( { useCornerstone: currentState.isCornerstone, diff --git a/packages/js/src/redux/actions/schemaTab.js b/packages/js/src/redux/actions/schemaTab.js index 722a59d53ef..cf9dfd011b6 100644 --- a/packages/js/src/redux/actions/schemaTab.js +++ b/packages/js/src/redux/actions/schemaTab.js @@ -1,4 +1,4 @@ -import SchemaFields from "../../helpers/SchemaFields"; +import SchemaFields from "../../helpers/fields/SchemaFields"; export const SET_PAGE_TYPE = "SET_PAGE_TYPE"; export const SET_ARTICLE_TYPE = "SET_ARTICLE_TYPE"; diff --git a/packages/js/src/shared-admin/constants/index.js b/packages/js/src/shared-admin/constants/index.js index 6ad9d28a529..608c42d7a33 100644 --- a/packages/js/src/shared-admin/constants/index.js +++ b/packages/js/src/shared-admin/constants/index.js @@ -23,3 +23,5 @@ export const VIDEO_FLOW = { }; export const FETCH_DELAY = 200; + +export * from "./meta-keys"; diff --git a/packages/js/src/shared-admin/constants/meta-keys.js b/packages/js/src/shared-admin/constants/meta-keys.js new file mode 100644 index 00000000000..2f0e43d7ec3 --- /dev/null +++ b/packages/js/src/shared-admin/constants/meta-keys.js @@ -0,0 +1,32 @@ +// Analysis meta keys. +export const metaKeyTitle = "_yoast_wpseo_title"; +export const metaKeyMetaDesc = "_yoast_wpseo_metadesc"; +export const metaKeyFocusKw = "_yoast_wpseo_focuskw"; +export const metaKeyIsCornerstone = "_yoast_wpseo_is_cornerstone"; +export const metaKeyEstimatedReadingTime = "_yoast_wpseo_estimated-reading-time-minutes"; +export const metaKeyLinkdex = "_yoast_wpseo_linkdex"; +export const metaKeyContentScore = "_yoast_wpseo_content_score"; +export const metaKeyInclusiveLanguageScore = "_yoast_wpseo_inclusive_language_score"; + +// Advanced meta keys. +export const metaKeyNoIndex = "_yoast_wpseo_meta-robots-noindex"; +export const metaKeyNoFollow = "_yoast_wpseo_meta-robots-nofollow"; +export const metaKeyAdvanced = "_yoast_wpseo_meta-robots-adv"; +export const metaKeyBcTitle = "_yoast_wpseo_bctitle"; +export const metaKeyCanonical = "_yoast_wpseo_canonical"; + +// Facebook / OpenGraph meta keys. +export const metaKeyOgTitle = "_yoast_wpseo_opengraph-title"; +export const metaKeyOgDescription = "_yoast_wpseo_opengraph-description"; +export const metaKeyOgImageId = "_yoast_wpseo_opengraph-image-id"; +export const metaKeyOgImage = "_yoast_wpseo_opengraph-image"; + +// Twitter meta keys. +export const metaKeyTwitterTitle = "_yoast_wpseo_twitter-title"; +export const metaKeyTwitterDescription = "_yoast_wpseo_twitter-description"; +export const metaKeyTwitterImageId = "_yoast_wpseo_twitter-image-id"; +export const metaKeyTwitterImage = "_yoast_wpseo_twitter-image"; + +// Schema meta keys. +export const metaKeySchemaArticleType = "_yoast_wpseo_schema_article_type"; +export const metaKeySchemaPageType = "_yoast_wpseo_schema_page_type"; diff --git a/packages/js/tests/ai-content-planner/hooks/use-yoast-meta-sync.test.js b/packages/js/tests/ai-content-planner/hooks/use-yoast-meta-sync.test.js index fc82802eae0..2b3989665ad 100644 --- a/packages/js/tests/ai-content-planner/hooks/use-yoast-meta-sync.test.js +++ b/packages/js/tests/ai-content-planner/hooks/use-yoast-meta-sync.test.js @@ -36,7 +36,10 @@ const setupUseSelect = ( meta = {}, postType = "post", templates = { title: "", beforeEach( () => { mockUpdateData.mockClear(); mockSetFocusKeyword.mockClear(); - useDispatch.mockReturnValue( { updateData: mockUpdateData, setFocusKeyword: mockSetFocusKeyword } ); + useDispatch.mockReturnValue( { + updateData: mockUpdateData, + setFocusKeyword: mockSetFocusKeyword, + } ); setupUseSelect(); } ); diff --git a/packages/js/tests/ai-content-planner/initialize.test.js b/packages/js/tests/ai-content-planner/initialize.test.js index 376e8829e3c..5182e4c8491 100644 --- a/packages/js/tests/ai-content-planner/initialize.test.js +++ b/packages/js/tests/ai-content-planner/initialize.test.js @@ -47,6 +47,7 @@ jest.mock( "@wordpress/data", () => ( { insertBlock: jest.fn(), updateData: jest.fn(), setFocusKeyword: jest.fn(), + setCornerstoneContent: jest.fn(), } ) ), select: jest.fn( () => ( { getBlocks: () => [], diff --git a/packages/js/tests/helpers/fields/AdvancedFields.test.js b/packages/js/tests/helpers/fields/AdvancedFields.test.js new file mode 100644 index 00000000000..99698dad065 --- /dev/null +++ b/packages/js/tests/helpers/fields/AdvancedFields.test.js @@ -0,0 +1,182 @@ +import AdvancedFields from "../../../src/helpers/fields/AdvancedFields"; +import { mockWindow, createElement } from "../../test-utils"; + +afterEach( () => { + document.body.innerHTML = ""; +} ); + +describe( "noIndexElement", () => { + it( "uses the post ID when isPost is true", () => { + const el = createElement( "yoast_wpseo_meta-robots-noindex" ); + const spy = mockWindow( { wpseoScriptData: { isPost: true } } ); + expect( AdvancedFields.noIndexElement ).toBe( el ); + spy.mockRestore(); + } ); + + it( "uses the term ID when isPost is false", () => { + const el = createElement( "hidden_wpseo_noindex" ); + const spy = mockWindow( { wpseoScriptData: { isPost: false } } ); + expect( AdvancedFields.noIndexElement ).toBe( el ); + spy.mockRestore(); + } ); +} ); + +describe( "noFollowElement", () => { + it( "returns null when absent", () => { + expect( AdvancedFields.noFollowElement ).toBeNull(); + } ); + + it( "returns the element when present", () => { + const el = createElement( "yoast_wpseo_meta-robots-nofollow" ); + expect( AdvancedFields.noFollowElement ).toBe( el ); + } ); +} ); + +describe( "advancedElement", () => { + it( "returns null when absent", () => { + expect( AdvancedFields.advancedElement ).toBeNull(); + } ); + + it( "returns the element when present", () => { + const el = createElement( "yoast_wpseo_meta-robots-adv" ); + expect( AdvancedFields.advancedElement ).toBe( el ); + } ); +} ); + +describe( "breadcrumbsTitleElement", () => { + it( "uses the post ID when isPost is true", () => { + const el = createElement( "yoast_wpseo_bctitle" ); + const spy = mockWindow( { wpseoScriptData: { isPost: true } } ); + expect( AdvancedFields.breadcrumbsTitleElement ).toBe( el ); + spy.mockRestore(); + } ); + + it( "uses the term ID when isPost is false", () => { + const el = createElement( "hidden_wpseo_bctitle" ); + const spy = mockWindow( { wpseoScriptData: { isPost: false } } ); + expect( AdvancedFields.breadcrumbsTitleElement ).toBe( el ); + spy.mockRestore(); + } ); +} ); + +describe( "canonicalElement", () => { + it( "uses the post ID when isPost is true", () => { + const el = createElement( "yoast_wpseo_canonical" ); + const spy = mockWindow( { wpseoScriptData: { isPost: true } } ); + expect( AdvancedFields.canonicalElement ).toBe( el ); + spy.mockRestore(); + } ); + + it( "uses the term ID when isPost is false", () => { + const el = createElement( "hidden_wpseo_canonical" ); + const spy = mockWindow( { wpseoScriptData: { isPost: false } } ); + expect( AdvancedFields.canonicalElement ).toBe( el ); + spy.mockRestore(); + } ); +} ); + +describe( "noIndex", () => { + it( "returns '0' as fallback when element is absent", () => { + const spy = mockWindow( { wpseoScriptData: { isPost: true } } ); + expect( AdvancedFields.noIndex ).toBe( "0" ); + spy.mockRestore(); + } ); + + it( "gets the value from the element", () => { + const el = createElement( "yoast_wpseo_meta-robots-noindex", "1" ); + const spy = mockWindow( { wpseoScriptData: { isPost: true } } ); + expect( AdvancedFields.noIndex ).toBe( "1" ); + el.remove(); + spy.mockRestore(); + } ); + + it( "sets the element value", () => { + const el = createElement( "yoast_wpseo_meta-robots-noindex" ); + const spy = mockWindow( { wpseoScriptData: { isPost: true } } ); + AdvancedFields.noIndex = "2"; + expect( el.value ).toBe( "2" ); + spy.mockRestore(); + } ); +} ); + +describe( "noFollow", () => { + it( "returns '0' as fallback when element is absent", () => { + expect( AdvancedFields.noFollow ).toBe( "0" ); + } ); + + it( "gets the value from the element", () => { + createElement( "yoast_wpseo_meta-robots-nofollow", "1" ); + expect( AdvancedFields.noFollow ).toBe( "1" ); + } ); + + it( "sets the element value", () => { + const el = createElement( "yoast_wpseo_meta-robots-nofollow" ); + AdvancedFields.noFollow = "1"; + expect( el.value ).toBe( "1" ); + } ); +} ); + +describe( "advanced", () => { + it( "returns an empty string when element is absent", () => { + expect( AdvancedFields.advanced ).toBe( "" ); + } ); + + it( "gets the value from the element", () => { + createElement( "yoast_wpseo_meta-robots-adv", "noodp" ); + expect( AdvancedFields.advanced ).toBe( "noodp" ); + } ); + + it( "sets the element value", () => { + const el = createElement( "yoast_wpseo_meta-robots-adv" ); + AdvancedFields.advanced = "noodp,noydir"; + expect( el.value ).toBe( "noodp,noydir" ); + } ); +} ); + +describe( "breadcrumbsTitle", () => { + it( "returns an empty string when element is absent", () => { + const spy = mockWindow( { wpseoScriptData: { isPost: true } } ); + expect( AdvancedFields.breadcrumbsTitle ).toBe( "" ); + spy.mockRestore(); + } ); + + it( "gets the value from the element", () => { + const el = createElement( "yoast_wpseo_bctitle", "My Breadcrumb" ); + const spy = mockWindow( { wpseoScriptData: { isPost: true } } ); + expect( AdvancedFields.breadcrumbsTitle ).toBe( "My Breadcrumb" ); + el.remove(); + spy.mockRestore(); + } ); + + it( "sets the element value", () => { + const el = createElement( "yoast_wpseo_bctitle" ); + const spy = mockWindow( { wpseoScriptData: { isPost: true } } ); + AdvancedFields.breadcrumbsTitle = "Custom Title"; + expect( el.value ).toBe( "Custom Title" ); + spy.mockRestore(); + } ); +} ); + +describe( "canonical", () => { + it( "returns an empty string when element is absent", () => { + const spy = mockWindow( { wpseoScriptData: { isPost: true } } ); + expect( AdvancedFields.canonical ).toBe( "" ); + spy.mockRestore(); + } ); + + it( "gets the value from the element", () => { + const el = createElement( "yoast_wpseo_canonical", "https://example.com/" ); + const spy = mockWindow( { wpseoScriptData: { isPost: true } } ); + expect( AdvancedFields.canonical ).toBe( "https://example.com/" ); + el.remove(); + spy.mockRestore(); + } ); + + it( "sets the element value", () => { + const el = createElement( "yoast_wpseo_canonical" ); + const spy = mockWindow( { wpseoScriptData: { isPost: true } } ); + AdvancedFields.canonical = "https://example.com/page/"; + expect( el.value ).toBe( "https://example.com/page/" ); + spy.mockRestore(); + } ); +} ); diff --git a/packages/js/tests/helpers/fields/AnalysisFields.test.js b/packages/js/tests/helpers/fields/AnalysisFields.test.js index d7ae9535d47..d48e16b4429 100644 --- a/packages/js/tests/helpers/fields/AnalysisFields.test.js +++ b/packages/js/tests/helpers/fields/AnalysisFields.test.js @@ -1,18 +1,44 @@ import AnalysisFields from "../../../src/helpers/fields/AnalysisFields"; -import { mockWindow } from "../../test-utils"; - -/** - * Creates an input element. - * @param {string} id The ID. - * @returns {HTMLInputElement} The input element. - */ -const createInputElement = ( id ) => { - const inputElement = document.createElement( "input" ); - inputElement.id = id; - document.body.appendChild( inputElement ); - - return inputElement; -}; +import { mockWindow, createElement } from "../../test-utils"; +import { metaKeyFocusKw, metaKeyLinkdex } from "../../../src/shared-admin/constants"; + +const mockGetEditedPostAttribute = jest.fn(); +const mockCurrentPostType = jest.fn(); +const mockCurrentPostId = jest.fn().mockReturnValue( 1 ); +const mockEditPost = jest.fn(); +const mockEditEntityRecord = jest.fn(); +const mockCapturedSubscribers = []; + +jest.mock( "@wordpress/data", () => ( { + select: ( store ) => { + if ( store === "core/editor" ) { + return { + getCurrentPostType: mockCurrentPostType, + getCurrentPostId: mockCurrentPostId, + getEditedPostAttribute: mockGetEditedPostAttribute, + }; + } + return {}; + }, + dispatch: ( store ) => { + if ( store === "core/editor" ) { + return { editPost: mockEditPost }; + } + if ( store === "core" ) { + return { editEntityRecord: mockEditEntityRecord }; + } + return {}; + }, + subscribe: jest.fn( ( fn ) => { + mockCapturedSubscribers.push( fn ); + return () => { + const index = mockCapturedSubscribers.indexOf( fn ); + if ( index > -1 ) { + mockCapturedSubscribers.splice( index, 1 ); + } + }; + } ), +} ) ); describe( "keyphrase", () => { const id = { @@ -26,7 +52,7 @@ describe( "keyphrase", () => { } ); it( "gets the element for non-posts by default", () => { - const inputElement = createInputElement( id.terms ); + const inputElement = createElement( id.terms ); expect( AnalysisFields.keyphraseElement ).toBe( inputElement ); @@ -34,7 +60,7 @@ describe( "keyphrase", () => { } ); it( "gets the element for posts", () => { - const inputElement = createInputElement( id.posts ); + const inputElement = createElement( id.posts ); const windowSpy = mockWindow( { wpseoScriptData: { isPost: true } } ); expect( AnalysisFields.keyphraseElement ).toBe( inputElement ); @@ -44,7 +70,7 @@ describe( "keyphrase", () => { } ); it( "gets the element for non-posts", () => { - const inputElement = createInputElement( id.terms ); + const inputElement = createElement( id.terms ); const windowSpy = mockWindow( { wpseoScriptData: { isPost: false } } ); expect( AnalysisFields.keyphraseElement ).toBe( inputElement ); @@ -60,7 +86,7 @@ describe( "keyphrase", () => { } ); it( "gets the keyphrase", () => { - const inputElement = createInputElement( id.terms ); + const inputElement = createElement( id.terms ); inputElement.value = "foo"; expect( AnalysisFields.keyphrase ).toBe( "foo" ); @@ -76,7 +102,7 @@ describe( "keyphrase", () => { } ); it( "sets the keyphrase", () => { - const inputElement = createInputElement( id.terms ); + const inputElement = createElement( id.terms ); AnalysisFields.keyphrase = "foo"; expect( AnalysisFields.keyphrase ).toBe( "foo" ); @@ -98,7 +124,7 @@ describe( "isCornerstone", () => { } ); it( "gets the element for non-posts by default", () => { - const inputElement = createInputElement( id.terms ); + const inputElement = createElement( id.terms ); expect( AnalysisFields.isCornerstoneElement ).toBe( inputElement ); @@ -106,7 +132,7 @@ describe( "isCornerstone", () => { } ); it( "gets the element for posts", () => { - const inputElement = createInputElement( id.posts ); + const inputElement = createElement( id.posts ); const windowSpy = mockWindow( { wpseoScriptData: { isPost: true } } ); expect( AnalysisFields.isCornerstoneElement ).toBe( inputElement ); @@ -116,7 +142,7 @@ describe( "isCornerstone", () => { } ); it( "gets the element for non-posts", () => { - const inputElement = createInputElement( id.terms ); + const inputElement = createElement( id.terms ); const windowSpy = mockWindow( { wpseoScriptData: { isPost: false } } ); expect( AnalysisFields.isCornerstoneElement ).toBe( inputElement ); @@ -132,7 +158,7 @@ describe( "isCornerstone", () => { } ); it( "gets isCornerstone", () => { - const inputElement = createInputElement( id.terms ); + const inputElement = createElement( id.terms ); inputElement.value = "1"; expect( AnalysisFields.isCornerstone ).toBe( true ); @@ -161,7 +187,7 @@ describe( "isCornerstone", () => { [ false, "0", null ], [ false, "0", undefined ], ] )( "should return %s with the value %s when setting %s", ( expected, expectedValue, input ) => { - const inputElement = createInputElement( id.terms ); + const inputElement = createElement( id.terms ); AnalysisFields.isCornerstone = input; expect( AnalysisFields.isCornerstone ).toBe( expected ); @@ -184,7 +210,7 @@ describe( "seoScore", () => { } ); it( "gets the element for non-posts by default", () => { - const inputElement = createInputElement( id.terms ); + const inputElement = createElement( id.terms ); expect( AnalysisFields.seoScoreElement ).toBe( inputElement ); @@ -192,7 +218,7 @@ describe( "seoScore", () => { } ); it( "gets the element for posts", () => { - const inputElement = createInputElement( id.posts ); + const inputElement = createElement( id.posts ); const windowSpy = mockWindow( { wpseoScriptData: { isPost: true } } ); expect( AnalysisFields.seoScoreElement ).toBe( inputElement ); @@ -202,7 +228,7 @@ describe( "seoScore", () => { } ); it( "gets the element for non-posts", () => { - const inputElement = createInputElement( id.terms ); + const inputElement = createElement( id.terms ); const windowSpy = mockWindow( { wpseoScriptData: { isPost: false } } ); expect( AnalysisFields.seoScoreElement ).toBe( inputElement ); @@ -218,7 +244,7 @@ describe( "seoScore", () => { } ); it( "gets the seoScore", () => { - const inputElement = createInputElement( id.terms ); + const inputElement = createElement( id.terms ); inputElement.value = "9"; expect( AnalysisFields.seoScore ).toBe( "9" ); @@ -234,7 +260,7 @@ describe( "seoScore", () => { } ); it( "sets the seoScore", () => { - const inputElement = createInputElement( id.terms ); + const inputElement = createElement( id.terms ); AnalysisFields.seoScore = "9"; expect( AnalysisFields.seoScore ).toBe( "9" ); @@ -256,7 +282,7 @@ describe( "readabilityScore", () => { } ); it( "gets the element for non-posts by default", () => { - const inputElement = createInputElement( id.terms ); + const inputElement = createElement( id.terms ); expect( AnalysisFields.readabilityScoreElement ).toBe( inputElement ); @@ -264,7 +290,7 @@ describe( "readabilityScore", () => { } ); it( "gets the element for posts", () => { - const inputElement = createInputElement( id.posts ); + const inputElement = createElement( id.posts ); const windowSpy = mockWindow( { wpseoScriptData: { isPost: true } } ); expect( AnalysisFields.readabilityScoreElement ).toBe( inputElement ); @@ -274,7 +300,7 @@ describe( "readabilityScore", () => { } ); it( "gets the element for non-posts", () => { - const inputElement = createInputElement( id.terms ); + const inputElement = createElement( id.terms ); const windowSpy = mockWindow( { wpseoScriptData: { isPost: false } } ); expect( AnalysisFields.readabilityScoreElement ).toBe( inputElement ); @@ -290,7 +316,7 @@ describe( "readabilityScore", () => { } ); it( "gets the readabilityScore", () => { - const inputElement = createInputElement( id.terms ); + const inputElement = createElement( id.terms ); inputElement.value = "9"; expect( AnalysisFields.readabilityScore ).toBe( "9" ); @@ -306,7 +332,7 @@ describe( "readabilityScore", () => { } ); it( "sets the readabilityScore", () => { - const inputElement = createInputElement( id.terms ); + const inputElement = createElement( id.terms ); AnalysisFields.readabilityScore = "9"; expect( AnalysisFields.readabilityScore ).toBe( "9" ); @@ -328,7 +354,7 @@ describe( "inclusiveLanguageScore", () => { } ); it( "gets the element for non-posts by default", () => { - const inputElement = createInputElement( id.terms ); + const inputElement = createElement( id.terms ); expect( AnalysisFields.inclusiveLanguageScoreElement ).toBe( inputElement ); @@ -336,7 +362,7 @@ describe( "inclusiveLanguageScore", () => { } ); it( "gets the element for posts", () => { - const inputElement = createInputElement( id.posts ); + const inputElement = createElement( id.posts ); const windowSpy = mockWindow( { wpseoScriptData: { isPost: true } } ); expect( AnalysisFields.inclusiveLanguageScoreElement ).toBe( inputElement ); @@ -346,7 +372,7 @@ describe( "inclusiveLanguageScore", () => { } ); it( "gets the element for non-posts", () => { - const inputElement = createInputElement( id.terms ); + const inputElement = createElement( id.terms ); const windowSpy = mockWindow( { wpseoScriptData: { isPost: false } } ); expect( AnalysisFields.inclusiveLanguageScoreElement ).toBe( inputElement ); @@ -362,7 +388,7 @@ describe( "inclusiveLanguageScore", () => { } ); it( "gets the inclusiveLanguageScore", () => { - const inputElement = createInputElement( id.terms ); + const inputElement = createElement( id.terms ); inputElement.value = "9"; expect( AnalysisFields.inclusiveLanguageScore ).toBe( "9" ); @@ -378,7 +404,7 @@ describe( "inclusiveLanguageScore", () => { } ); it( "sets the inclusiveLanguageScore", () => { - const inputElement = createInputElement( id.terms ); + const inputElement = createElement( id.terms ); AnalysisFields.inclusiveLanguageScore = "9"; expect( AnalysisFields.inclusiveLanguageScore ).toBe( "9" ); @@ -387,3 +413,70 @@ describe( "inclusiveLanguageScore", () => { } ); } ); } ); + +describe( "pending writes (REST meta mode)", () => { + beforeEach( () => { + // Flush any leftover module state from a previous test by simulating the editor becoming ready. + mockCurrentPostType.mockReturnValue( "post" ); + [ ...mockCapturedSubscribers ].forEach( fn => fn() ); + mockCapturedSubscribers.length = 0; + + window.wpseoScriptData = { isPost: true, disableMetaboxInBlockEditor: true }; + mockCurrentPostType.mockReturnValue( null ); + mockGetEditedPostAttribute.mockReturnValue( {} ); + mockEditPost.mockClear(); + mockEditEntityRecord.mockClear(); + } ); + + afterEach( () => { + delete window.wpseoScriptData; + } ); + + it( "flushes score writes via writeMetaWithoutUndo when the editor becomes ready", () => { + AnalysisFields.seoScore = "9"; + + expect( mockEditEntityRecord ).not.toHaveBeenCalled(); + expect( mockEditPost ).not.toHaveBeenCalled(); + expect( mockCapturedSubscribers ).toHaveLength( 1 ); + + mockCurrentPostType.mockReturnValue( "post" ); + mockCapturedSubscribers[ 0 ](); + + expect( mockEditEntityRecord ).toHaveBeenCalledWith( + "postType", "post", 1, + { meta: { [ metaKeyLinkdex ]: "9" } }, + { undoIgnore: true } + ); + expect( mockEditPost ).not.toHaveBeenCalled(); + } ); + + it( "flushes user-editable writes via editPost when the editor becomes ready", () => { + mockGetEditedPostAttribute.mockReturnValue( { [ metaKeyFocusKw ]: "" } ); + AnalysisFields.keyphrase = "test keyword"; + + expect( mockEditPost ).not.toHaveBeenCalled(); + expect( mockCapturedSubscribers ).toHaveLength( 1 ); + + mockCurrentPostType.mockReturnValue( "post" ); + mockCapturedSubscribers[ 0 ](); + + expect( mockEditPost ).toHaveBeenCalledWith( { meta: { [ metaKeyFocusKw ]: "test keyword" } } ); + expect( mockEditEntityRecord ).not.toHaveBeenCalled(); + } ); + + it( "splits mixed writes into separate editEntityRecord and editPost dispatches on flush", () => { + mockGetEditedPostAttribute.mockReturnValue( { [ metaKeyFocusKw ]: "" } ); + AnalysisFields.seoScore = "5"; + AnalysisFields.keyphrase = "mixed"; + + mockCurrentPostType.mockReturnValue( "post" ); + mockCapturedSubscribers[ 0 ](); + + expect( mockEditEntityRecord ).toHaveBeenCalledWith( + "postType", "post", 1, + { meta: { [ metaKeyLinkdex ]: "5" } }, + { undoIgnore: true } + ); + expect( mockEditPost ).toHaveBeenCalledWith( { meta: { [ metaKeyFocusKw ]: "mixed" } } ); + } ); +} ); diff --git a/packages/js/tests/helpers/fields/EstimatedReadingTimeFields.test.js b/packages/js/tests/helpers/fields/EstimatedReadingTimeFields.test.js new file mode 100644 index 00000000000..d05f0694c6b --- /dev/null +++ b/packages/js/tests/helpers/fields/EstimatedReadingTimeFields.test.js @@ -0,0 +1,45 @@ +import EstimatedReadingTimeFields from "../../../src/helpers/fields/EstimatedReadingTimeFields"; +import { createElement } from "../../test-utils"; + +const ELEMENT_ID = "yoast_wpseo_estimated-reading-time-minutes"; + +afterEach( () => { + document.body.innerHTML = ""; +} ); + +describe( "estimatedReadingTimeElement", () => { + it( "returns null when the element is absent", () => { + expect( EstimatedReadingTimeFields.estimatedReadingTimeElement ).toBeNull(); + } ); + + it( "returns the element when present", () => { + const el = createElement( ELEMENT_ID ); + expect( EstimatedReadingTimeFields.estimatedReadingTimeElement ).toBe( el ); + } ); +} ); + +describe( "estimatedReadingTime getter", () => { + it( "returns an empty string when the element is absent", () => { + expect( EstimatedReadingTimeFields.estimatedReadingTime ).toBe( "" ); + } ); + + it( "returns the element value", () => { + const el = createElement( ELEMENT_ID ); + el.value = "5"; + expect( EstimatedReadingTimeFields.estimatedReadingTime ).toBe( "5" ); + } ); +} ); + +describe( "estimatedReadingTime setter", () => { + it( "does nothing when the element is absent", () => { + expect( () => { + EstimatedReadingTimeFields.estimatedReadingTime = "5"; + } ).not.toThrow(); + } ); + + it( "sets the element value", () => { + createElement( ELEMENT_ID ); + EstimatedReadingTimeFields.estimatedReadingTime = "7"; + expect( EstimatedReadingTimeFields.estimatedReadingTime ).toBe( "7" ); + } ); +} ); diff --git a/packages/js/tests/helpers/fields/FacebookFields.test.js b/packages/js/tests/helpers/fields/FacebookFields.test.js new file mode 100644 index 00000000000..3e2b8bda1dd --- /dev/null +++ b/packages/js/tests/helpers/fields/FacebookFields.test.js @@ -0,0 +1,135 @@ +import FacebookFields from "../../../src/helpers/fields/FacebookFields"; +import { mockWindow, createElement } from "../../test-utils"; + +beforeEach( () => { + window.wpseoScriptData = { isPost: true }; +} ); + +afterEach( () => { + document.body.innerHTML = ""; + delete window.wpseoScriptData; +} ); + +describe( "titleElement", () => { + it( "uses the post element ID when isPost is true", () => { + const el = createElement( "yoast_wpseo_opengraph-title" ); + expect( FacebookFields.titleElement ).toBe( el ); + } ); + + it( "uses the term element ID when isPost is false", () => { + const spy = mockWindow( { wpseoScriptData: { isPost: false } } ); + const el = createElement( "hidden_wpseo_opengraph-title" ); + expect( FacebookFields.titleElement ).toBe( el ); + spy.mockRestore(); + } ); +} ); + +describe( "descriptionElement", () => { + it( "uses the post element ID when isPost is true", () => { + const el = createElement( "yoast_wpseo_opengraph-description" ); + expect( FacebookFields.descriptionElement ).toBe( el ); + } ); + + it( "uses the term element ID when isPost is false", () => { + const spy = mockWindow( { wpseoScriptData: { isPost: false } } ); + const el = createElement( "hidden_wpseo_opengraph-description" ); + expect( FacebookFields.descriptionElement ).toBe( el ); + spy.mockRestore(); + } ); +} ); + +describe( "imageIdElement", () => { + it( "uses the post element ID when isPost is true", () => { + const el = createElement( "yoast_wpseo_opengraph-image-id" ); + expect( FacebookFields.imageIdElement ).toBe( el ); + } ); + + it( "uses the term element ID when isPost is false", () => { + const spy = mockWindow( { wpseoScriptData: { isPost: false } } ); + const el = createElement( "hidden_wpseo_opengraph-image-id" ); + expect( FacebookFields.imageIdElement ).toBe( el ); + spy.mockRestore(); + } ); +} ); + +describe( "imageUrlElement", () => { + it( "uses the post element ID when isPost is true", () => { + const el = createElement( "yoast_wpseo_opengraph-image" ); + expect( FacebookFields.imageUrlElement ).toBe( el ); + } ); + + it( "uses the term element ID when isPost is false", () => { + const spy = mockWindow( { wpseoScriptData: { isPost: false } } ); + const el = createElement( "hidden_wpseo_opengraph-image" ); + expect( FacebookFields.imageUrlElement ).toBe( el ); + spy.mockRestore(); + } ); +} ); + +describe( "title", () => { + it( "returns an empty string when element is absent", () => { + expect( FacebookFields.title ).toBe( "" ); + } ); + + it( "gets the value from the element", () => { + createElement( "yoast_wpseo_opengraph-title", "OG Title" ); + expect( FacebookFields.title ).toBe( "OG Title" ); + } ); + + it( "sets the element value", () => { + const el = createElement( "yoast_wpseo_opengraph-title" ); + FacebookFields.title = "New OG Title"; + expect( el.value ).toBe( "New OG Title" ); + } ); +} ); + +describe( "description", () => { + it( "returns an empty string when element is absent", () => { + expect( FacebookFields.description ).toBe( "" ); + } ); + + it( "gets the value from the element", () => { + createElement( "yoast_wpseo_opengraph-description", "OG description" ); + expect( FacebookFields.description ).toBe( "OG description" ); + } ); + + it( "sets the element value", () => { + const el = createElement( "yoast_wpseo_opengraph-description" ); + FacebookFields.description = "New OG description"; + expect( el.value ).toBe( "New OG description" ); + } ); +} ); + +describe( "imageId", () => { + it( "returns an empty string when element is absent", () => { + expect( FacebookFields.imageId ).toBe( "" ); + } ); + + it( "gets the value from the element", () => { + createElement( "yoast_wpseo_opengraph-image-id", "42" ); + expect( FacebookFields.imageId ).toBe( "42" ); + } ); + + it( "sets the element value", () => { + const el = createElement( "yoast_wpseo_opengraph-image-id" ); + FacebookFields.imageId = "99"; + expect( el.value ).toBe( "99" ); + } ); +} ); + +describe( "imageUrl", () => { + it( "returns an empty string when element is absent", () => { + expect( FacebookFields.imageUrl ).toBe( "" ); + } ); + + it( "gets the value from the element", () => { + createElement( "yoast_wpseo_opengraph-image", "https://example.com/img.jpg" ); + expect( FacebookFields.imageUrl ).toBe( "https://example.com/img.jpg" ); + } ); + + it( "sets the element value", () => { + const el = createElement( "yoast_wpseo_opengraph-image" ); + FacebookFields.imageUrl = "https://example.com/new.jpg"; + expect( el.value ).toBe( "https://example.com/new.jpg" ); + } ); +} ); diff --git a/packages/js/tests/helpers/fields/PrimaryTermFields.test.js b/packages/js/tests/helpers/fields/PrimaryTermFields.test.js new file mode 100644 index 00000000000..bfbfba74794 --- /dev/null +++ b/packages/js/tests/helpers/fields/PrimaryTermFields.test.js @@ -0,0 +1,65 @@ +jest.mock( "../../../src/helpers/fields/rest-meta", () => ( { + getMetaValue: jest.fn(), + setMetaValue: jest.fn(), +} ) ); + +import { createElement } from "../../test-utils"; +import { getMetaValue, setMetaValue } from "../../../src/helpers/fields/rest-meta"; +import PrimaryTermFields from "../../../src/helpers/fields/PrimaryTermFields"; + +afterEach( () => { + jest.clearAllMocks(); +} ); + +describe( "PrimaryTermFields.getPrimaryTermElement", () => { + it( "returns element.value when element is provided", () => { + const el = createElement( "fieldId", "42" ); + expect( PrimaryTermFields.getPrimaryTermElement( "fieldId" ) ).toBe( el ); + } ); +} ); + +describe( "PrimaryTermFields.get", () => { + it( "calls getMetaValue with the correct meta key for the taxonomy", () => { + const el = createElement( "field-get-1", "5" ); + PrimaryTermFields.get( "category", "field-get-1" ); + expect( getMetaValue ).toHaveBeenCalledWith( "_yoast_wpseo_primary_category", el, "" ); + } ); + + it( "returns the value from getMetaValue", () => { + createElement( "field-get-2", "5" ); + getMetaValue.mockReturnValue( "99" ); + const result = PrimaryTermFields.get( "category", "field-get-2" ); + expect( result ).toBe( "99" ); + } ); + + it( "builds the meta key from the taxonomy name", () => { + createElement( "field-get-3" ); + PrimaryTermFields.get( "post_tag", "field-get-3" ); + expect( getMetaValue ).toHaveBeenCalledWith( "_yoast_wpseo_primary_post_tag", expect.anything(), "" ); + } ); +} ); + +describe( "PrimaryTermFields.set", () => { + it( "calls setMetaValue with the term ID coerced to string", () => { + const el = createElement( "field-set-1" ); + PrimaryTermFields.set( "category", "field-set-1", 42 ); + expect( setMetaValue ).toHaveBeenCalledWith( "_yoast_wpseo_primary_category", el, "42" ); + } ); + + it( "passes empty string when termId is -1 (clear selection)", () => { + const el = createElement( "field-set-2" ); + PrimaryTermFields.set( "category", "field-set-2", -1 ); + expect( setMetaValue ).toHaveBeenCalledWith( "_yoast_wpseo_primary_category", el, "" ); + } ); + + it( "builds the meta key from the taxonomy name", () => { + const el = createElement( "field-set-3" ); + PrimaryTermFields.set( "post_tag", "field-set-3", 7 ); + expect( setMetaValue ).toHaveBeenCalledWith( "_yoast_wpseo_primary_post_tag", el, "7" ); + } ); + + it( "passes null element through to setMetaValue", () => { + PrimaryTermFields.set( "category", "non-existent-field", 5 ); + expect( setMetaValue ).toHaveBeenCalledWith( "_yoast_wpseo_primary_category", null, "5" ); + } ); +} ); diff --git a/packages/js/tests/helpers/fields/SchemaFields.test.js b/packages/js/tests/helpers/fields/SchemaFields.test.js new file mode 100644 index 00000000000..47407fc7702 --- /dev/null +++ b/packages/js/tests/helpers/fields/SchemaFields.test.js @@ -0,0 +1,102 @@ +import SchemaFields from "../../../src/helpers/fields/SchemaFields"; +import { mockWindow, createElement } from "../../test-utils"; + +afterEach( () => { + document.body.innerHTML = ""; +} ); + +describe( "articleTypeInput", () => { + it( "returns null when the element is absent", () => { + expect( SchemaFields.articleTypeInput ).toBeNull(); + } ); + + it( "returns the element when present", () => { + const el = createElement( "yoast_wpseo_schema_article_type" ); + expect( SchemaFields.articleTypeInput ).toBe( el ); + } ); +} ); + +describe( "defaultArticleType", () => { + it( "returns an empty string when wpseoScriptData is absent", () => { + expect( SchemaFields.defaultArticleType ).toBe( "" ); + } ); + + it( "returns the value from wpseoScriptData", () => { + const spy = mockWindow( { wpseoScriptData: { metabox: { schema: { defaultArticleType: "Article" } } } } ); + expect( SchemaFields.defaultArticleType ).toBe( "Article" ); + spy.mockRestore(); + } ); +} ); + +describe( "articleType getter", () => { + it( "returns an empty string when the element is absent", () => { + expect( SchemaFields.articleType ).toBe( "" ); + } ); + + it( "returns the element value", () => { + createElement( "yoast_wpseo_schema_article_type", "BlogPosting" ); + expect( SchemaFields.articleType ).toBe( "BlogPosting" ); + } ); +} ); + +describe( "articleType setter", () => { + it( "does nothing when the element is absent", () => { + expect( () => { + SchemaFields.articleType = "Article"; + } ).not.toThrow(); + } ); + + it( "sets the element value", () => { + createElement( "yoast_wpseo_schema_article_type" ); + SchemaFields.articleType = "NewsArticle"; + expect( SchemaFields.articleType ).toBe( "NewsArticle" ); + } ); +} ); + +describe( "pageTypeInput", () => { + it( "returns null when the element is absent", () => { + expect( SchemaFields.pageTypeInput ).toBeNull(); + } ); + + it( "returns the element when present", () => { + const el = createElement( "yoast_wpseo_schema_page_type" ); + expect( SchemaFields.pageTypeInput ).toBe( el ); + } ); +} ); + +describe( "defaultPageType", () => { + it( "returns an empty string when wpseoScriptData is absent", () => { + expect( SchemaFields.defaultPageType ).toBe( "" ); + } ); + + it( "returns the value from wpseoScriptData", () => { + const spy = mockWindow( { wpseoScriptData: { metabox: { schema: { defaultPageType: "WebPage" } } } } ); + expect( SchemaFields.defaultPageType ).toBe( "WebPage" ); + spy.mockRestore(); + } ); +} ); + +describe( "pageType getter", () => { + it( "returns an empty string when the element is absent", () => { + expect( SchemaFields.pageType ).toBe( "" ); + } ); + + it( "returns the element value", () => { + createElement( "yoast_wpseo_schema_page_type", "AboutPage" ); + expect( SchemaFields.pageType ).toBe( "AboutPage" ); + } ); +} ); + +describe( "pageType setter", () => { + it( "does nothing when the element is absent", () => { + expect( () => { + SchemaFields.pageType = "WebPage"; + } ).not.toThrow(); + } ); + + it( "sets the element value", () => { + createElement( "yoast_wpseo_schema_page_type" ); + SchemaFields.pageType = "CollectionPage"; + expect( SchemaFields.pageType ).toBe( "CollectionPage" ); + } ); +} ); diff --git a/packages/js/tests/helpers/fields/SearchMetadataFields.test.js b/packages/js/tests/helpers/fields/SearchMetadataFields.test.js new file mode 100644 index 00000000000..914d97bf132 --- /dev/null +++ b/packages/js/tests/helpers/fields/SearchMetadataFields.test.js @@ -0,0 +1,145 @@ +import SearchMetadataFields from "../../../src/helpers/fields/SearchMetadataFields"; +import { mockWindow, createElement } from "../../test-utils"; +import { metaKeyTitle, metaKeyMetaDesc } from "../../../src/shared-admin/constants"; + +const mockGetEditedPostAttribute = jest.fn(); +const mockEditPost = jest.fn(); + +jest.mock( "@wordpress/data", () => ( { + select: () => ( { getEditedPostAttribute: mockGetEditedPostAttribute } ), + dispatch: () => ( { editPost: mockEditPost } ), +} ) ); + +beforeEach( () => { + window.wpseoScriptData = { isPost: true }; +} ); + +afterEach( () => { + document.body.innerHTML = ""; + delete window.wpseoScriptData; +} ); + +describe( "titleElement", () => { + it( "uses the post element ID when isPost is true", () => { + const el = createElement( "yoast_wpseo_title" ); + expect( SearchMetadataFields.titleElement ).toBe( el ); + } ); + + it( "uses the term element ID when isPost is false", () => { + const spy = mockWindow( { wpseoScriptData: { isPost: false } } ); + const el = createElement( "hidden_wpseo_title" ); + expect( SearchMetadataFields.titleElement ).toBe( el ); + spy.mockRestore(); + } ); +} ); + +describe( "descriptionElement", () => { + it( "uses the post element ID when isPost is true", () => { + const el = createElement( "yoast_wpseo_metadesc" ); + expect( SearchMetadataFields.descriptionElement ).toBe( el ); + } ); + + it( "uses the term element ID when isPost is false", () => { + const spy = mockWindow( { wpseoScriptData: { isPost: false } } ); + const el = createElement( "hidden_wpseo_desc" ); + expect( SearchMetadataFields.descriptionElement ).toBe( el ); + spy.mockRestore(); + } ); +} ); + +describe( "slugElement", () => { + it( "returns null when absent", () => { + expect( SearchMetadataFields.slugElement ).toBeNull(); + } ); + + it( "returns the element when present", () => { + const el = createElement( "yoast_wpseo_slug" ); + expect( SearchMetadataFields.slugElement ).toBe( el ); + } ); +} ); + +describe( "title", () => { + it( "gets the value from the element", () => { + createElement( "yoast_wpseo_title", "My Title" ); + expect( SearchMetadataFields.title ).toBe( "My Title" ); + } ); + + it( "sets the element value", () => { + const el = createElement( "yoast_wpseo_title" ); + SearchMetadataFields.title = "New Title"; + expect( el.value ).toBe( "New Title" ); + } ); +} ); + +describe( "description", () => { + it( "gets the value from the element", () => { + createElement( "yoast_wpseo_metadesc", "My description" ); + expect( SearchMetadataFields.description ).toBe( "My description" ); + } ); + + it( "sets the element value", () => { + const el = createElement( "yoast_wpseo_metadesc" ); + SearchMetadataFields.description = "New description"; + expect( el.value ).toBe( "New description" ); + } ); +} ); + +describe( "slug", () => { + it( "gets the value from the element", () => { + createElement( "yoast_wpseo_slug", "my-post" ); + expect( SearchMetadataFields.slug ).toBe( "my-post" ); + } ); + + it( "sets the element value", () => { + const el = createElement( "yoast_wpseo_slug" ); + SearchMetadataFields.slug = "new-slug"; + expect( el.value ).toBe( "new-slug" ); + } ); +} ); + +describe( "title (REST meta mode)", () => { + beforeEach( () => { + window.wpseoScriptData = { isPost: true, disableMetaboxInBlockEditor: true }; + mockEditPost.mockClear(); + } ); + + it( "reads the title from the store", () => { + mockGetEditedPostAttribute.mockReturnValue( { [ metaKeyTitle ]: "Store Title" } ); + expect( SearchMetadataFields.title ).toBe( "Store Title" ); + } ); + + it( "returns an empty string when the key is absent", () => { + mockGetEditedPostAttribute.mockReturnValue( {} ); + expect( SearchMetadataFields.title ).toBe( "" ); + } ); + + it( "dispatches the new title to the store", () => { + mockGetEditedPostAttribute.mockReturnValue( { [ metaKeyTitle ]: "Old" } ); + SearchMetadataFields.title = "New Title"; + expect( mockEditPost ).toHaveBeenCalledWith( { meta: { [ metaKeyTitle ]: "New Title" } } ); + } ); + + it( "skips the dispatch when the title is unchanged", () => { + mockGetEditedPostAttribute.mockReturnValue( { [ metaKeyTitle ]: "Same" } ); + SearchMetadataFields.title = "Same"; + expect( mockEditPost ).not.toHaveBeenCalled(); + } ); +} ); + +describe( "description (REST meta mode)", () => { + beforeEach( () => { + window.wpseoScriptData = { isPost: true, disableMetaboxInBlockEditor: true }; + mockEditPost.mockClear(); + } ); + + it( "reads the description from the store", () => { + mockGetEditedPostAttribute.mockReturnValue( { [ metaKeyMetaDesc ]: "Store Desc" } ); + expect( SearchMetadataFields.description ).toBe( "Store Desc" ); + } ); + + it( "dispatches the new description to the store", () => { + mockGetEditedPostAttribute.mockReturnValue( { [ metaKeyMetaDesc ]: "Old" } ); + SearchMetadataFields.description = "New Desc"; + expect( mockEditPost ).toHaveBeenCalledWith( { meta: { [ metaKeyMetaDesc ]: "New Desc" } } ); + } ); +} ); diff --git a/packages/js/tests/helpers/fields/TwitterFields.test.js b/packages/js/tests/helpers/fields/TwitterFields.test.js new file mode 100644 index 00000000000..c103b0438d2 --- /dev/null +++ b/packages/js/tests/helpers/fields/TwitterFields.test.js @@ -0,0 +1,135 @@ +import TwitterFields from "../../../src/helpers/fields/TwitterFields"; +import { mockWindow, createElement } from "../../test-utils"; + +beforeEach( () => { + window.wpseoScriptData = { isPost: true }; +} ); + +afterEach( () => { + document.body.innerHTML = ""; + delete window.wpseoScriptData; +} ); + +describe( "titleElement", () => { + it( "uses the post element ID when isPost is true", () => { + const el = createElement( "yoast_wpseo_twitter-title" ); + expect( TwitterFields.titleElement ).toBe( el ); + } ); + + it( "uses the term element ID when isPost is false", () => { + const spy = mockWindow( { wpseoScriptData: { isPost: false } } ); + const el = createElement( "hidden_wpseo_twitter-title" ); + expect( TwitterFields.titleElement ).toBe( el ); + spy.mockRestore(); + } ); +} ); + +describe( "descriptionElement", () => { + it( "uses the post element ID when isPost is true", () => { + const el = createElement( "yoast_wpseo_twitter-description" ); + expect( TwitterFields.descriptionElement ).toBe( el ); + } ); + + it( "uses the term element ID when isPost is false", () => { + const spy = mockWindow( { wpseoScriptData: { isPost: false } } ); + const el = createElement( "hidden_wpseo_twitter-description" ); + expect( TwitterFields.descriptionElement ).toBe( el ); + spy.mockRestore(); + } ); +} ); + +describe( "imageIdElement", () => { + it( "uses the post element ID when isPost is true", () => { + const el = createElement( "yoast_wpseo_twitter-image-id" ); + expect( TwitterFields.imageIdElement ).toBe( el ); + } ); + + it( "uses the term element ID when isPost is false", () => { + const spy = mockWindow( { wpseoScriptData: { isPost: false } } ); + const el = createElement( "hidden_wpseo_twitter-image-id" ); + expect( TwitterFields.imageIdElement ).toBe( el ); + spy.mockRestore(); + } ); +} ); + +describe( "imageUrlElement", () => { + it( "uses the post element ID when isPost is true", () => { + const el = createElement( "yoast_wpseo_twitter-image" ); + expect( TwitterFields.imageUrlElement ).toBe( el ); + } ); + + it( "uses the term element ID when isPost is false", () => { + const spy = mockWindow( { wpseoScriptData: { isPost: false } } ); + const el = createElement( "hidden_wpseo_twitter-image" ); + expect( TwitterFields.imageUrlElement ).toBe( el ); + spy.mockRestore(); + } ); +} ); + +describe( "title", () => { + it( "returns an empty string when element is absent", () => { + expect( TwitterFields.title ).toBe( "" ); + } ); + + it( "gets the value from the element", () => { + createElement( "yoast_wpseo_twitter-title", "Twitter Title" ); + expect( TwitterFields.title ).toBe( "Twitter Title" ); + } ); + + it( "sets the element value", () => { + const el = createElement( "yoast_wpseo_twitter-title" ); + TwitterFields.title = "New Twitter Title"; + expect( el.value ).toBe( "New Twitter Title" ); + } ); +} ); + +describe( "description", () => { + it( "returns an empty string when element is absent", () => { + expect( TwitterFields.description ).toBe( "" ); + } ); + + it( "gets the value from the element", () => { + createElement( "yoast_wpseo_twitter-description", "Twitter description" ); + expect( TwitterFields.description ).toBe( "Twitter description" ); + } ); + + it( "sets the element value", () => { + const el = createElement( "yoast_wpseo_twitter-description" ); + TwitterFields.description = "New Twitter description"; + expect( el.value ).toBe( "New Twitter description" ); + } ); +} ); + +describe( "imageId", () => { + it( "returns an empty string when element is absent", () => { + expect( TwitterFields.imageId ).toBe( "" ); + } ); + + it( "gets the value from the element", () => { + createElement( "yoast_wpseo_twitter-image-id", "42" ); + expect( TwitterFields.imageId ).toBe( "42" ); + } ); + + it( "sets the element value", () => { + const el = createElement( "yoast_wpseo_twitter-image-id" ); + TwitterFields.imageId = "99"; + expect( el.value ).toBe( "99" ); + } ); +} ); + +describe( "imageUrl", () => { + it( "returns an empty string when element is absent", () => { + expect( TwitterFields.imageUrl ).toBe( "" ); + } ); + + it( "gets the value from the element", () => { + createElement( "yoast_wpseo_twitter-image", "https://example.com/img.jpg" ); + expect( TwitterFields.imageUrl ).toBe( "https://example.com/img.jpg" ); + } ); + + it( "sets the element value", () => { + const el = createElement( "yoast_wpseo_twitter-image" ); + TwitterFields.imageUrl = "https://example.com/new.jpg"; + expect( el.value ).toBe( "https://example.com/new.jpg" ); + } ); +} ); diff --git a/packages/js/tests/helpers/fields/rest-meta.test.js b/packages/js/tests/helpers/fields/rest-meta.test.js new file mode 100644 index 00000000000..1b403b89d75 --- /dev/null +++ b/packages/js/tests/helpers/fields/rest-meta.test.js @@ -0,0 +1,204 @@ +jest.mock( "@wordpress/data", () => ( { + select: jest.fn(), + dispatch: jest.fn(), +} ) ); + +import { select, dispatch } from "@wordpress/data"; +import { shouldSkipMetaWrite, writeMetaWithoutUndo, getMetaValue, setMetaValue } from "../../../src/helpers/fields/rest-meta"; +import { metaKeyTitle, metaKeyLinkdex, metaKeyContentScore } from "../../../src/shared-admin/constants/meta-keys"; + +describe( "shouldSkipMetaWrite", () => { + afterEach( () => { + jest.clearAllMocks(); + } ); + + it( "returns true when entity meta is null (not yet loaded)", () => { + select.mockReturnValue( { getEditedPostAttribute: () => null } ); + expect( shouldSkipMetaWrite( metaKeyTitle, "value" ) ).toBe( true ); + } ); + + it( "returns true when the new value equals the current meta value", () => { + select.mockReturnValue( { getEditedPostAttribute: () => ( { [ metaKeyTitle ]: "same" } ) } ); + expect( shouldSkipMetaWrite( metaKeyTitle, "same" ) ).toBe( true ); + } ); + + it( "returns false when the new value differs from the current meta value", () => { + select.mockReturnValue( { getEditedPostAttribute: () => ( { [ metaKeyTitle ]: "old" } ) } ); + expect( shouldSkipMetaWrite( metaKeyTitle, "new" ) ).toBe( false ); + } ); + + it( "coerces the new value to string before comparing", () => { + select.mockReturnValue( { getEditedPostAttribute: () => ( { [ metaKeyLinkdex ]: "100" } ) } ); + expect( shouldSkipMetaWrite( metaKeyLinkdex, 100 ) ).toBe( true ); + } ); +} ); + +describe( "writeMetaWithoutUndo", () => { + const mockEditEntityRecord = jest.fn(); + + beforeEach( () => { + select.mockReturnValue( { + getCurrentPostType: () => "post", + getCurrentPostId: () => 42, + } ); + dispatch.mockImplementation( ( store ) => { + if ( store === "core" ) { + return { editEntityRecord: mockEditEntityRecord }; + } + return {}; + } ); + } ); + + afterEach( () => { + jest.clearAllMocks(); + } ); + + it( "calls editEntityRecord with undoIgnore: true", () => { + writeMetaWithoutUndo( { [ metaKeyLinkdex ]: "100" } ); + expect( mockEditEntityRecord ).toHaveBeenCalledWith( + "postType", "post", 42, + { meta: { [ metaKeyLinkdex ]: "100" } }, + { undoIgnore: true } + ); + } ); + + it( "passes the full meta object through", () => { + writeMetaWithoutUndo( { [ metaKeyLinkdex ]: "50", [ metaKeyContentScore ]: "80" } ); + expect( mockEditEntityRecord ).toHaveBeenCalledWith( + "postType", "post", 42, + { meta: { [ metaKeyLinkdex ]: "50", [ metaKeyContentScore ]: "80" } }, + { undoIgnore: true } + ); + } ); +} ); + +// isRestMetaActive is false by default (no window.wpseoScriptData set). +describe( "getMetaValue (REST meta inactive)", () => { + it( "returns element.value when element is provided", () => { + expect( getMetaValue( metaKeyTitle, { value: "dom-value" }, "" ) ).toBe( "dom-value" ); + } ); + + it( "returns the fallback when element is null", () => { + expect( getMetaValue( metaKeyTitle, null, "fallback" ) ).toBe( "fallback" ); + } ); + + it( "returns the fallback when element.value is undefined", () => { + expect( getMetaValue( metaKeyTitle, {}, "fallback" ) ).toBe( "fallback" ); + } ); +} ); + +describe( "setMetaValue (REST meta inactive)", () => { + it( "sets element.value to the given value", () => { + const element = { value: "" }; + setMetaValue( metaKeyTitle, element, "new-value" ); + expect( element.value ).toBe( "new-value" ); + } ); + + it( "does nothing when element is null", () => { + expect( () => setMetaValue( metaKeyTitle, null, "value" ) ).not.toThrow(); + } ); +} ); + +// Reload the module with isRestMetaActive = true for the REST-active path tests. +describe( "getMetaValue (REST meta active)", () => { + let getMetaValueActive; + const mockGetEditedPostAttribute = jest.fn(); + + beforeEach( () => { + jest.resetModules(); + window.wpseoScriptData = { disableMetaboxInBlockEditor: true }; + jest.doMock( "@wordpress/data", () => ( { + select: jest.fn( () => ( { getEditedPostAttribute: mockGetEditedPostAttribute } ) ), + dispatch: jest.fn(), + } ) ); + ( { getMetaValue: getMetaValueActive } = require( "../../../src/helpers/fields/rest-meta" ) ); + } ); + + afterEach( () => { + delete window.wpseoScriptData; + jest.clearAllMocks(); + } ); + + it( "reads the value from the core/editor store", () => { + mockGetEditedPostAttribute.mockReturnValue( { [ metaKeyTitle ]: "REST title" } ); + expect( getMetaValueActive( metaKeyTitle, null, "" ) ).toBe( "REST title" ); + } ); + + it( "returns the fallback when the meta key is absent", () => { + mockGetEditedPostAttribute.mockReturnValue( {} ); + expect( getMetaValueActive( metaKeyTitle, null, "fallback" ) ).toBe( "fallback" ); + } ); + + it( "ignores the DOM element and reads from the store", () => { + mockGetEditedPostAttribute.mockReturnValue( { [ metaKeyTitle ]: "REST title" } ); + expect( getMetaValueActive( metaKeyTitle, { value: "dom-value" }, "" ) ).toBe( "REST title" ); + } ); +} ); + +describe( "setMetaValue (REST meta active)", () => { + let setMetaValueActive; + const mockGetEditedPostAttribute = jest.fn(); + const mockEditPost = jest.fn(); + const mockEditEntityRecord = jest.fn(); + + beforeEach( () => { + jest.resetModules(); + window.wpseoScriptData = { disableMetaboxInBlockEditor: true }; + jest.doMock( "@wordpress/data", () => ( { + select: jest.fn( () => ( { + getEditedPostAttribute: mockGetEditedPostAttribute, + getCurrentPostType: () => "post", + getCurrentPostId: () => 1, + } ) ), + dispatch: jest.fn( ( store ) => { + if ( store === "core/editor" ) { + return { editPost: mockEditPost }; + } + if ( store === "core" ) { + return { editEntityRecord: mockEditEntityRecord }; + } + return {}; + } ), + } ) ); + ( { setMetaValue: setMetaValueActive } = require( "../../../src/helpers/fields/rest-meta" ) ); + } ); + + afterEach( () => { + delete window.wpseoScriptData; + jest.clearAllMocks(); + } ); + + it( "dispatches editPost when withoutUndo is false (default)", () => { + mockGetEditedPostAttribute.mockReturnValue( { [ metaKeyTitle ]: "old" } ); + setMetaValueActive( metaKeyTitle, null, "new" ); + expect( mockEditPost ).toHaveBeenCalledWith( { meta: { [ metaKeyTitle ]: "new" } } ); + } ); + + it( "coerces value to string before dispatching", () => { + mockGetEditedPostAttribute.mockReturnValue( { [ metaKeyLinkdex ]: "0" } ); + setMetaValueActive( metaKeyLinkdex, null, 100 ); + expect( mockEditPost ).toHaveBeenCalledWith( { meta: { [ metaKeyLinkdex ]: "100" } } ); + } ); + + it( "skips dispatch when the value already matches the current meta", () => { + mockGetEditedPostAttribute.mockReturnValue( { [ metaKeyTitle ]: "same" } ); + setMetaValueActive( metaKeyTitle, null, "same" ); + expect( mockEditPost ).not.toHaveBeenCalled(); + } ); + + it( "dispatches editEntityRecord when withoutUndo is true", () => { + setMetaValueActive( metaKeyLinkdex, null, "100", true ); + expect( mockEditEntityRecord ).toHaveBeenCalledWith( + "postType", "post", 1, + { meta: { [ metaKeyLinkdex ]: "100" } }, + { undoIgnore: true } + ); + } ); + + it( "does not write to the DOM element in REST mode", () => { + mockGetEditedPostAttribute.mockReturnValue( {} ); + const element = { value: "original" }; + setMetaValueActive( metaKeyTitle, element, "new" ); + expect( element.value ).toBe( "original" ); + } ); +} ); diff --git a/packages/js/tests/test-utils.js b/packages/js/tests/test-utils.js index 783a58becf5..ac602f0071c 100644 --- a/packages/js/tests/test-utils.js +++ b/packages/js/tests/test-utils.js @@ -47,3 +47,16 @@ export const withConsoleErrorMock = ( fn ) => { console.error = consoleErrorImplementation; } }; + +/** + * Create input element by id. + * @param {string} id The id of the element to create. + * @returns {HTMLElement} The created element. + */ +export const createElement = ( id, value = "" ) => { + const el = document.createElement( "input" ); + el.id = id; + el.value = value; + document.body.appendChild( el ); + return el; +}; diff --git a/src/initializers/post-meta-rest-fields.php b/src/initializers/post-meta-rest-fields.php new file mode 100644 index 00000000000..f3b7a38d69f --- /dev/null +++ b/src/initializers/post-meta-rest-fields.php @@ -0,0 +1,255 @@ +taxonomy_helper = $taxonomy_helper; + $this->options_helper = $options_helper; + $this->capability_helper = $capability_helper; + } + + /** + * Initializes the post meta REST field registrations. + * + * @return void + */ + public function initialize() { + \add_action( 'wp_loaded', [ $this, 'register_post_meta' ] ); + } + + /** + * Registers all Yoast meta fields per REST-enabled post type and adds the + * unauthorized-read filter for each. + * + * Also populates WPSEO_Meta::$fields_index and WPSEO_Meta::$defaults, which were + * previously built inside the registration loop in WPSEO_Meta::init(). + * + * @return void + */ + public function register_post_meta() { + foreach ( WPSEO_Meta::$meta_fields as $subset => $field_group ) { + foreach ( $field_group as $key => $field_def ) { + $full_key = WPSEO_Meta::$meta_prefix . $key; + + WPSEO_Meta::$fields_index[ $full_key ] = [ + 'subset' => $subset, + 'key' => $key, + ]; + WPSEO_Meta::$defaults[ $full_key ] = ( $field_def['default_value'] ?? '' ); + } + } + + $metabox_disabled_in_block_editor = \apply_filters( 'wpseo_disable_metabox_in_block_editor', false ); + + foreach ( \get_post_types( [ 'show_in_rest' => true ], 'names' ) as $post_type ) { + foreach ( WPSEO_Meta::$meta_fields as $subset => $field_group ) { + $requires_advanced_cap = \in_array( $subset, [ 'advanced', 'schema' ], true ); + foreach ( $field_group as $key => $field_def ) { + $this->register_meta( $post_type, $key, $field_def, $requires_advanced_cap ); + } + } + + $this->register_primary_term_meta( $post_type ); + + // Without custom-fields support, WordPress omits registered meta from the REST schema for CPTs + // (WP_REST_Posts_Controller::get_item_schema), so the block editor cannot read or write Yoast + // fields via REST. Only needed when our metabox is disabled in the block editor; otherwise the + // sidebar handles saving and classic-editor post types are unaffected. + if ( + $metabox_disabled_in_block_editor + && \use_block_editor_for_post_type( $post_type ) + && ! \post_type_supports( $post_type, 'custom-fields' ) + ) { + \add_post_type_support( $post_type, 'custom-fields' ); + } + + // Add a filter to strip REST-exposed Yoast meta fields from the response for users without edit_post capability. + \add_filter( 'rest_prepare_' . $post_type, [ $this, 'hide_meta_from_unauthorized_rest_response' ], 10, 2 ); + + // Add a filter to trigger wpseo_saved_postdata after a post is updated via REST API, same as in WPSEO_Metabox::save_postdata. + // The $creating guard ensures it only fires on updates (not on new post creation via REST), matching the + // classic-editor save_postdata path which is only reachable when a post already exists with an ID in $_POST. + if ( $metabox_disabled_in_block_editor ) { + \add_action( + 'rest_after_insert_' . $post_type, + static function ( $post, $request, $creating ) { + if ( ! $creating ) { + \do_action( 'wpseo_saved_postdata' ); + } + }, + 10, + 3, + ); + } + } + } + + /** + * Registers primary term meta for all non-excluded hierarchical taxonomies on a post type. + * + * @param string $post_type The post type slug. + * + * @return void + */ + private function register_primary_term_meta( string $post_type ) { + foreach ( \get_object_taxonomies( $post_type, 'objects' ) as $taxonomy ) { + if ( ! $taxonomy->hierarchical || $this->taxonomy_helper->is_excluded( $taxonomy->name ) ) { + continue; + } + + $primary_key = 'primary_' . $taxonomy->name; + $full_key = WPSEO_Meta::$meta_prefix . $primary_key; + + if ( ! isset( WPSEO_Meta::$fields_index[ $full_key ] ) ) { + WPSEO_Meta::$meta_fields['primary_term'][ $primary_key ] = [ 'type' => 'hidden' ]; + WPSEO_Meta::$fields_index[ $full_key ] = [ + 'subset' => 'primary_term', + 'key' => $primary_key, + ]; + WPSEO_Meta::$defaults[ $full_key ] = '-1'; + } + + $this->register_meta( + $post_type, + $primary_key, + [ + 'type' => 'hidden', + ], + ); + } + } + + /** + * Registers a single Yoast meta field for a specific post type. + * + * Fields with `type: null` are internal/serialized fields not suitable for REST API + * access and will be registered with `show_in_rest: false`. + * + * @param string $post_type The post type slug. + * @param string $key The internal key of the meta field (without prefix). + * @param array> $field_def The field definition array. + * @param bool $requires_advanced_cap Whether the field is in the advanced or schema subset + * and therefore also requires wpseo_edit_advanced_metadata. + * + * @return void + */ + private function register_meta( string $post_type, string $key, array $field_def = [], bool $requires_advanced_cap = false ) { + $show_in_rest = ! \array_key_exists( 'type', $field_def ) || $field_def['type'] !== null; + + $args = [ + 'show_in_rest' => $show_in_rest, + 'single' => true, + 'type' => 'string', + 'sanitize_callback' => [ WPSEO_Meta::class, 'sanitize_post_meta' ], + ]; + + if ( $requires_advanced_cap ) { + $args['auth_callback'] = [ $this, 'auth_callback_for_advanced_meta' ]; + } + else { + $args['auth_callback'] = static function ( $allowed, $meta_key, $object_id ) { + return \current_user_can( 'edit_post', $object_id ); + }; + } + + \register_post_meta( $post_type, WPSEO_Meta::$meta_prefix . $key, $args ); + } + + /** + * Auth callback for advanced and schema meta fields. + * + * Mirrors the gate in WPSEO_Meta::get_tab_field_defs(): when disableadvanced_meta is on, + * only users with wpseo_edit_advanced_metadata (or wpseo_manage_options) may write these fields. + * + * @param bool $allowed Whether the user is allowed (unused; re-evaluated from scratch). + * @param string $meta_key The meta key being written. + * @param int $object_id The post ID. + * + * @return bool + */ + public function auth_callback_for_advanced_meta( $allowed, $meta_key, $object_id ) { + if ( ! \current_user_can( 'edit_post', $object_id ) ) { + return false; + } + return ! $this->options_helper->get( 'disableadvanced_meta' ) + || $this->capability_helper->current_user_can( 'wpseo_edit_advanced_metadata' ); + } + + /** + * Strips REST-exposed Yoast meta fields from the response for users without edit_post capability. + * + * The register_meta's auth_callback only covers writes; read access must be restricted separately. + * + * @param WP_REST_Response $response The REST response. + * @param WP_Post $post The post object. + * + * @return WP_REST_Response The (possibly modified) response. + */ + public function hide_meta_from_unauthorized_rest_response( $response, $post ) { + if ( \current_user_can( 'edit_post', $post->ID ) ) { + return $response; + } + $data = $response->get_data(); + foreach ( WPSEO_Meta::$meta_fields as $field_group ) { + foreach ( $field_group as $key => $field_def ) { + // Mirror the show_in_rest logic from register_meta(): only expose fields whose type is not null. + if ( ! \array_key_exists( 'type', $field_def ) || $field_def['type'] !== null ) { + unset( $data['meta'][ WPSEO_Meta::$meta_prefix . $key ] ); + } + } + } + $response->set_data( $data ); + return $response; + } +} diff --git a/src/integrations/estimated-reading-time.php b/src/integrations/estimated-reading-time.php index 01b514e86e4..0e1015e189f 100644 --- a/src/integrations/estimated-reading-time.php +++ b/src/integrations/estimated-reading-time.php @@ -12,7 +12,7 @@ class Estimated_Reading_Time implements Integration_Interface { /** * Returns the conditionals based in which this loadable should be active. * - * @return array + * @return array */ public static function get_conditionals() { return [ Estimated_Reading_Time_Conditional::class ]; @@ -26,24 +26,29 @@ public static function get_conditionals() { * @return void */ public function register_hooks() { - \add_filter( 'wpseo_metabox_entries_general', [ $this, 'add_estimated_reading_time_hidden_fields' ] ); + \add_filter( 'add_extra_wpseo_meta_fields', [ $this, 'add_estimated_reading_time_hidden_fields' ] ); } /** - * Adds an estimated-reading-time hidden field. + * Adds the estimated-reading-time hidden field to the Yoast meta fields. * - * @param array $field_defs The $fields_defs. + * Hooked to `add_extra_wpseo_meta_fields` so the field is registered for + * REST API access and the metabox via the normal WPSEO_Meta::init() loop. * - * @return array + * @param array>> $extra_fields The extra meta fields passed through the filter. + * + * @return array>> */ - public function add_estimated_reading_time_hidden_fields( $field_defs ) { - if ( \is_array( $field_defs ) ) { - $field_defs['estimated-reading-time-minutes'] = [ - 'type' => 'hidden', - 'title' => 'estimated-reading-time-minutes', - ]; + public function add_estimated_reading_time_hidden_fields( $extra_fields ) { + if ( ! \is_array( $extra_fields ) ) { + $extra_fields = []; } - return $field_defs; + $extra_fields['general']['estimated-reading-time-minutes'] = [ + 'type' => 'hidden', + 'title' => 'estimated-reading-time-minutes', + ]; + + return $extra_fields; } } diff --git a/tests/Unit/Integrations/Estimated_Reading_Time_Test.php b/tests/Unit/Integrations/Estimated_Reading_Time_Test.php index e7cdfc9aded..662ce299592 100644 --- a/tests/Unit/Integrations/Estimated_Reading_Time_Test.php +++ b/tests/Unit/Integrations/Estimated_Reading_Time_Test.php @@ -42,7 +42,7 @@ public function set_up() { * @return void */ public function test_register_hooks() { - Monkey\Filters\expectAdded( 'wpseo_metabox_entries_general' ) + Monkey\Filters\expectAdded( 'add_extra_wpseo_meta_fields' ) ->with( [ $this->instance, 'add_estimated_reading_time_hidden_fields' ] ); $this->instance->register_hooks(); @@ -73,26 +73,29 @@ public function test_add_estimated_reading_time_hidden_fields() { $actual = $this->instance->add_estimated_reading_time_hidden_fields( [] ); $this->assertIsArray( $actual ); - $this->assertArrayHasKey( 'estimated-reading-time-minutes', $actual ); + $this->assertArrayHasKey( 'general', $actual ); + $this->assertArrayHasKey( 'estimated-reading-time-minutes', $actual['general'] ); $this->assertEquals( [ 'type' => 'hidden', 'title' => 'estimated-reading-time-minutes', ], - $actual['estimated-reading-time-minutes'], + $actual['general']['estimated-reading-time-minutes'], ); } /** - * Tests only adding when the fields value is an array. + * Tests that a non-array input is treated as an empty array. * * @covers ::add_estimated_reading_time_hidden_fields * * @return void */ - public function test_add_estimated_reading_time_hidden_fields_only_when_array() { + public function test_add_estimated_reading_time_hidden_fields_when_not_array() { $actual = $this->instance->add_estimated_reading_time_hidden_fields( 'not-an-array' ); - $this->assertSame( 'not-an-array', $actual ); + $this->assertIsArray( $actual ); + $this->assertArrayHasKey( 'general', $actual ); + $this->assertArrayHasKey( 'estimated-reading-time-minutes', $actual['general'] ); } } diff --git a/tests/WP/Initializers/Post_Meta_Rest_Fields_Test.php b/tests/WP/Initializers/Post_Meta_Rest_Fields_Test.php new file mode 100644 index 00000000000..2dba119c406 --- /dev/null +++ b/tests/WP/Initializers/Post_Meta_Rest_Fields_Test.php @@ -0,0 +1,362 @@ +instance = \YoastSEO()->classes->get( Post_Meta_Rest_Fields::class ); + } + + /** + * Restores the disableadvanced_meta option to its default state (true) so + * it does not bleed into subsequent tests. + * + * @return void + */ + public function tear_down() { + $this->set_disable_advanced_meta( true ); + parent::tear_down(); + } + + // ------------------------------------------------------------------------- + // Registration + // ------------------------------------------------------------------------- + + /** + * Tests that register_post_meta registers a general-subset field for the post + * post type with show_in_rest enabled. + * + * The register_post_meta() is hooked to wp_loaded and runs during the WordPress + * test bootstrap, so keys are already registered by the time tests execute. + * We verify against the post-type-specific registration (object_subtype = 'post'). + * + * @covers ::register_post_meta + * + * @return void + */ + public function test_register_post_meta_registers_general_key_with_show_in_rest() { + $this->instance->register_post_meta(); + $key = WPSEO_Meta::$meta_prefix . 'title'; + $registered = \get_registered_meta_keys( 'post', 'post' ); + + $this->assertArrayHasKey( $key, $registered ); + $this->assertTrue( $registered[ $key ]['show_in_rest'] ); + $this->assertTrue( $registered[ $key ]['single'] ); + } + + /** + * Tests that register_post_meta registers an advanced-subset field (noindex) + * for the "post" post type with show_in_rest enabled. + * + * @covers ::register_post_meta + * + * @return void + */ + public function test_register_post_meta_registers_noindex_key_with_show_in_rest() { + $this->instance->register_post_meta(); + $key = WPSEO_Meta::$meta_prefix . 'meta-robots-noindex'; + $registered = \get_registered_meta_keys( 'post', 'post' ); + + $this->assertArrayHasKey( $key, $registered ); + $this->assertTrue( $registered[ $key ]['show_in_rest'] ); + } + + /** + * Tests that register_post_meta registers a schema-subset field for the "post" + * post type with show_in_rest enabled. + * + * @covers ::register_post_meta + * + * @return void + */ + public function test_register_post_meta_registers_schema_key_with_show_in_rest() { + $this->instance->register_post_meta(); + $key = WPSEO_Meta::$meta_prefix . 'schema_page_type'; + $registered = \get_registered_meta_keys( 'post', 'post' ); + + $this->assertArrayHasKey( $key, $registered ); + $this->assertTrue( $registered[ $key ]['show_in_rest'] ); + } + + /** + * Tests that register_post_meta populates WPSEO_Meta::$fields_index for all + * registered fields. Calling it a second time is safe — the fields_index + * update is idempotent. + * + * @covers ::register_post_meta + * + * @return void + */ + public function test_register_post_meta_populates_fields_index() { + $this->instance->register_post_meta(); + + $this->assertArrayHasKey( + WPSEO_Meta::$meta_prefix . 'title', + WPSEO_Meta::$fields_index, + ); + $this->assertSame( + 'general', + WPSEO_Meta::$fields_index[ WPSEO_Meta::$meta_prefix . 'title' ]['subset'], + ); + + $this->assertArrayHasKey( + WPSEO_Meta::$meta_prefix . 'meta-robots-noindex', + WPSEO_Meta::$fields_index, + ); + $this->assertSame( + 'advanced', + WPSEO_Meta::$fields_index[ WPSEO_Meta::$meta_prefix . 'meta-robots-noindex' ]['subset'], + ); + } + + // ------------------------------------------------------------------------- + // auth_callback_for_advanced_meta + // ------------------------------------------------------------------------- + + /** + * Tests that auth_callback_for_advanced_meta returns false for a user who + * lacks edit_post on the target post, regardless of the disableadvanced_meta + * setting. + * + * @covers ::auth_callback_for_advanced_meta + * + * @return void + */ + public function test_auth_callback_denies_user_without_edit_post_capability() { + $subscriber_id = $this->factory->user->create( [ 'role' => 'subscriber' ] ); + $post_id = $this->factory->post->create(); + \wp_set_current_user( $subscriber_id ); + + $result = $this->instance->auth_callback_for_advanced_meta( + true, + WPSEO_Meta::$meta_prefix . 'meta-robots-noindex', + $post_id, + ); + + $this->assertFalse( $result ); + } + + /** + * Tests that auth_callback_for_advanced_meta returns false for an author who + * has edit_post on their own post but lacks wpseo_edit_advanced_metadata when + * disableadvanced_meta is on. + * + * @covers ::auth_callback_for_advanced_meta + * + * @return void + */ + public function test_auth_callback_denies_author_when_advanced_meta_restricted() { + $this->set_disable_advanced_meta( true ); + + $author_id = $this->factory->user->create( [ 'role' => 'author' ] ); + $post_id = $this->factory->post->create( [ 'post_author' => $author_id ] ); + \wp_set_current_user( $author_id ); + + $result = $this->instance->auth_callback_for_advanced_meta( + true, + WPSEO_Meta::$meta_prefix . 'meta-robots-noindex', + $post_id, + ); + + $this->assertFalse( $result ); + } + + /** + * Tests that auth_callback_for_advanced_meta returns true for an author when + * disableadvanced_meta is off — the option gate is the only barrier. + * + * @covers ::auth_callback_for_advanced_meta + * + * @return void + */ + public function test_auth_callback_allows_author_when_advanced_meta_not_restricted() { + $this->set_disable_advanced_meta( false ); + + $author_id = $this->factory->user->create( [ 'role' => 'author' ] ); + $post_id = $this->factory->post->create( [ 'post_author' => $author_id ] ); + \wp_set_current_user( $author_id ); + + $result = $this->instance->auth_callback_for_advanced_meta( + true, + WPSEO_Meta::$meta_prefix . 'meta-robots-noindex', + $post_id, + ); + + $this->assertTrue( $result ); + } + + /** + * Tests that auth_callback_for_advanced_meta returns true for a user who has + * the wpseo_edit_advanced_metadata capability even when disableadvanced_meta + * is on. + * + * @covers ::auth_callback_for_advanced_meta + * + * @return void + */ + public function test_auth_callback_allows_user_with_advanced_metadata_cap() { + $this->set_disable_advanced_meta( true ); + + $author_id = $this->factory->user->create( [ 'role' => 'author' ] ); + $post_id = $this->factory->post->create( [ 'post_author' => $author_id ] ); + + $user = \get_user_by( 'id', $author_id ); + $user->add_cap( 'wpseo_edit_advanced_metadata' ); + \wp_set_current_user( $author_id ); + + $result = $this->instance->auth_callback_for_advanced_meta( + true, + WPSEO_Meta::$meta_prefix . 'meta-robots-noindex', + $post_id, + ); + + $user->remove_cap( 'wpseo_edit_advanced_metadata' ); + $this->assertTrue( $result ); + } + + /** + * Tests that auth_callback_for_advanced_meta returns true for a user who has + * the wpseo_manage_options capability (the superuser cap checked by + * Capability_Helper), even without wpseo_edit_advanced_metadata explicitly. + * + * @covers ::auth_callback_for_advanced_meta + * + * @return void + */ + public function test_auth_callback_allows_user_with_manage_options_cap() { + $this->set_disable_advanced_meta( true ); + + $admin_id = $this->factory->user->create( [ 'role' => 'administrator' ] ); + $post_id = $this->factory->post->create( [ 'post_author' => $admin_id ] ); + \wp_set_current_user( $admin_id ); + + $result = $this->instance->auth_callback_for_advanced_meta( + true, + WPSEO_Meta::$meta_prefix . 'meta-robots-noindex', + $post_id, + ); + + $this->assertTrue( $result ); + } + + // ------------------------------------------------------------------------- + // hide_meta_from_unauthorized_rest_response + // ------------------------------------------------------------------------- + + /** + * Tests that hide_meta_from_unauthorized_rest_response returns the response + * unchanged for a user who can edit the post. + * + * @covers ::hide_meta_from_unauthorized_rest_response + * + * @return void + */ + public function test_hide_meta_returns_response_unchanged_for_editor() { + $editor_id = $this->factory->user->create( [ 'role' => 'editor' ] ); + $post_id = $this->factory->post->create(); + \wp_set_current_user( $editor_id ); + + $post = \get_post( $post_id ); + $prefix = WPSEO_Meta::$meta_prefix; + $original_data = [ + 'title' => 'My Post', + 'meta' => [ + $prefix . 'title' => 'SEO Title', + $prefix . 'schema_page_type' => 'WebPage', + ], + ]; + $response = new WP_REST_Response( $original_data ); + + $result = $this->instance->hide_meta_from_unauthorized_rest_response( $response, $post ); + + $this->assertSame( $original_data, $result->get_data() ); + } + + /** + * Tests that hide_meta_from_unauthorized_rest_response strips all Yoast meta + * fields from the response for a user who cannot edit the post, while leaving + * unrelated meta keys intact. + * + * @covers ::hide_meta_from_unauthorized_rest_response + * + * @return void + */ + public function test_hide_meta_strips_yoast_fields_for_subscriber() { + $subscriber_id = $this->factory->user->create( [ 'role' => 'subscriber' ] ); + $post_id = $this->factory->post->create(); + \wp_set_current_user( $subscriber_id ); + + $post = \get_post( $post_id ); + $prefix = WPSEO_Meta::$meta_prefix; + $response = new WP_REST_Response( + [ + 'title' => 'My Post', + 'meta' => [ + $prefix . 'title' => 'SEO Title', + $prefix . 'schema_page_type' => 'WebPage', + 'unrelated_meta_key' => 'keep_me', + ], + ], + ); + + $result = $this->instance->hide_meta_from_unauthorized_rest_response( $response, $post ); + $data = $result->get_data(); + + $this->assertArrayNotHasKey( $prefix . 'title', $data['meta'] ); + $this->assertArrayNotHasKey( $prefix . 'schema_page_type', $data['meta'] ); + $this->assertArrayHasKey( 'unrelated_meta_key', $data['meta'] ); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** + * Sets the disableadvanced_meta option and clears the WPSEO_Options cache so + * the new value is immediately visible to Options_Helper::get(). + * + * @param bool $value The value to set. + * + * @return void + */ + private function set_disable_advanced_meta( bool $value ): void { + $wpseo = \get_option( 'wpseo', [] ); + $wpseo['disableadvanced_meta'] = $value; + \update_option( 'wpseo', $wpseo ); + WPSEO_Options::clear_cache(); + } +}