diff --git a/admin/metabox/class-metabox.php b/admin/metabox/class-metabox.php index 48eab592982..12cbfceb556 100644 --- a/admin/metabox/class-metabox.php +++ b/admin/metabox/class-metabox.php @@ -893,7 +893,8 @@ public function enqueue() { 'has_taxonomies' => $this->current_post_type_has_taxonomies(), ], 'shortcodes' => [ - 'wpseo_shortcode_tags' => $this->get_valid_shortcode_tags(), + 'wpseo_shortcode_tags' => $this->get_valid_shortcode_tags(), + 'wpseo_filter_shortcodes_nonce' => \wp_create_nonce( 'wpseo-filter-shortcodes' ), ], ]; diff --git a/admin/taxonomy/class-taxonomy.php b/admin/taxonomy/class-taxonomy.php index 884055343c6..4fa581c45af 100644 --- a/admin/taxonomy/class-taxonomy.php +++ b/admin/taxonomy/class-taxonomy.php @@ -164,7 +164,8 @@ public function admin_enqueue_scripts() { 'scope' => $this->determine_scope(), ], 'shortcodes' => [ - 'wpseo_shortcode_tags' => $this->get_valid_shortcode_tags(), + 'wpseo_shortcode_tags' => $this->get_valid_shortcode_tags(), + 'wpseo_filter_shortcodes_nonce' => \wp_create_nonce( 'wpseo-filter-shortcodes' ), ], ], 'worker' => [ diff --git a/packages/js/src/analysis/plugins/shortcode-plugin.js b/packages/js/src/analysis/plugins/shortcode-plugin.js new file mode 100644 index 00000000000..37578c59c23 --- /dev/null +++ b/packages/js/src/analysis/plugins/shortcode-plugin.js @@ -0,0 +1,352 @@ +/* global tinyMCE */ +/* global wpseoScriptData */ +/* global ajaxurl */ +import { debounce, flatten } from "lodash"; +import { applyFilters } from "@wordpress/hooks"; +import { actions } from "@yoast/externals/redux"; + +const SHORTCODE_NAME_MATCHER = "[^<>&/\\[\\]\x00-\x20=]+?"; +const SHORTCODE_ATTRIBUTES_MATCHER = "( [^\\]]+?)?"; + +const SHORTCODE_START_REGEX = new RegExp( "\\[" + SHORTCODE_NAME_MATCHER + SHORTCODE_ATTRIBUTES_MATCHER + "\\]", "g" ); +const SHORTCODE_END_REGEX = new RegExp( "\\[/" + SHORTCODE_NAME_MATCHER + "\\]", "g" ); + +/** + * @typedef ParsedShortcode + * @type {object} + * @property {string} shortcode The unparsed shortcode. + * @property {string} output The parsed shortcode. + */ + +/** + * The Yoast Shortcode plugin parses the shortcodes in a given piece of text. It analyzes multiple input fields for + * shortcodes which it will preload using AJAX. + */ +class YoastShortcodePlugin { + /** + * Constructs the YoastShortcodePlugin. + * + * @property {RegExp} shortcodesRegex Used to match a given string for a valid shortcode. + * @property {RegExp} closingTagRegex Used to match a given string for shortcode closing tags. + * @property {RegExp} nonCaptureRegex Used to match a given string for non-capturing shortcodes. + * @property {ParsedShortcode[]} parsedShortcodes Used to store parsed shortcodes. + * + * @param {Object} interface Object Formerly Known as App, but for backwards compatibility + * still passed here as one argument. + * @param {function} interface.registerPlugin Register a plugin with Yoast SEO. + * @param {function} interface.registerModification Register a modification with Yoast SEO. + * @param {function} interface.pluginReady Notify Yoast SEO that the plugin is ready. + * @param {function} interface.pluginReloaded Notify Yoast SEO that the plugin has been reloaded. + * @param {string[]} shortcodesToBeParsed The array of shortcodes to be parsed. + * @returns {void} + */ + constructor( { registerPlugin, registerModification, pluginReady, pluginReloaded }, shortcodesToBeParsed ) { + this._registerModification = registerModification; + this._pluginReady = pluginReady; + this._pluginReloaded = pluginReloaded; + + registerPlugin( "YoastShortcodePlugin", { status: "loading" } ); + this.bindElementEvents(); + + const shortcodesRegexString = "(" + shortcodesToBeParsed.join( "|" ) + ")"; + + // The regex for matching shortcodes based on the list of shortcodes. + this.shortcodesRegex = new RegExp( shortcodesRegexString, "g" ); + this.closingTagRegex = new RegExp( "\\[\\/" + shortcodesRegexString + "\\]", "g" ); + this.nonCaptureRegex = new RegExp( "\\[" + shortcodesRegexString + "[^\\]]*?\\]", "g" ); + + /** + * The array of parsed shortcode objects. + * @type {ParsedShortcode[]} + */ + this.parsedShortcodes = []; + + this.loadShortcodes( this.declareReady.bind( this ) ); + } + + /* YOAST SEO CLIENT */ + + /** + * Declares ready with YoastSEO. + * + * @returns {void} + */ + declareReady() { + this._pluginReady( "YoastShortcodePlugin" ); + this.registerModifications(); + } + + /** + * Declares reloaded with YoastSEO. + * + * @returns {void} + */ + declareReloaded() { + this._pluginReloaded( "YoastShortcodePlugin" ); + } + + /** + * Registers the modifications for the content in which we want to replace shortcodes. + * + * @returns {void} + */ + registerModifications() { + this._registerModification( "content", this.replaceShortcodes.bind( this ), "YoastShortcodePlugin" ); + } + + /** + * Removes all unknown shortcodes. Not all plugins properly registered their shortcodes in the WordPress backend. + * Since we cannot use the data from these shortcodes they must be removed. + * + * @param {string} data The text to remove unknown shortcodes. + * @returns {string} The text with removed unknown shortcodes. + */ + removeUnknownShortCodes( data ) { + data = data.replace( SHORTCODE_START_REGEX, "" ); + data = data.replace( SHORTCODE_END_REGEX, "" ); + + return data; + } + + /** + * Replaces the unparsed shortcodes with the parsed ones. + * The callback used to replace the shortcodes. + * + * @param {string} data The text to replace the shortcodes in. + * + * @returns {string} The text with replaced shortcodes. + */ + replaceShortcodes( data ) { + if ( typeof data === "string" ) { + this.parsedShortcodes.forEach( ( { shortcode, output } ) => { + data = data.replace( shortcode, output ); + } ); + } + + data = this.removeUnknownShortCodes( data ); + + return data; + } + + /* DATA SOURCING */ + + /** + * Gets data from input fields and store them in an analyzerData object. This object will be used to fill + * the analyzer and the snippet preview. + * + * @param {function} callback To declare either ready or reloaded after parsing. + * + * @returns {function} The callback function. + */ + loadShortcodes( callback ) { + const unparsedShortcodes = this.getUnparsedShortcodes( this.getShortcodes( this.getContentTinyMCE() ) ); + if ( unparsedShortcodes.length > 0 ) { + this.parseShortcodes( unparsedShortcodes, callback ); + } else { + return callback(); + } + } + + /** + * Binds elements to be able to reload the dataset if shortcodes get added. + * + * @returns {void} + */ + bindElementEvents() { + const contentElement = document.querySelector( ".wp-editor-area" ); + const callback = debounce( this.loadShortcodes.bind( this, this.declareReloaded.bind( this ) ), 500 ); + + if ( contentElement ) { + contentElement.addEventListener( "keyup", callback ); + contentElement.addEventListener( "change", callback ); + } + + if ( typeof tinyMCE !== "undefined" && typeof tinyMCE.on === "function" ) { + tinyMCE.on( "addEditor", function( e ) { + e.editor.on( "change", callback ); + e.editor.on( "keyup", callback ); + } ); + } + } + + /** + * Gets content from the content field, if tinyMCE is initialized, use the getContent function to + * get the data from tinyMCE. + * + * @returns {string} The content from tinyMCE. + */ + getContentTinyMCE() { + let content = document.querySelector( ".wp-editor-area" ) ? document.querySelector( ".wp-editor-area" ).value : ""; + if ( typeof tinyMCE !== "undefined" && typeof tinyMCE.editors !== "undefined" && tinyMCE.editors.length !== 0 ) { + content = tinyMCE.get( "content" ) ? tinyMCE.get( "content" ).getContent() : ""; + } + + return content; + } + + /* SHORTCODE PARSING */ + + /** + * Returns the unparsed shortcodes out of a collection of shortcodes. + * + * @param {Array} shortcodes The shortcodes to check. + * + * @returns {boolean|Array} Array with unparsed shortcodes. + */ + getUnparsedShortcodes( shortcodes ) { + if ( typeof shortcodes !== "object" ) { + console.error( "Failed to get unparsed shortcodes. Expected parameter to be an array, instead received " + typeof shortcodes ); + return false; + } + + return shortcodes.filter( shortcode => this.isUnparsedShortcode( shortcode ) ); + } + + /** + * Checks if a given shortcode was already parsed. + * + * @param {string} shortcodeToCheck The shortcode to check. + * + * @returns {boolean} True when shortcode is not parsed yet. + */ + isUnparsedShortcode( shortcodeToCheck ) { + return ! this.parsedShortcodes.some( ( { shortcode } ) => shortcode === shortcodeToCheck ); + } + + /** + * Gets the shortcodes from a given piece of text. + * + * @param {string} text Text to extract shortcodes from. + * + * @returns {boolean|Array} The matched shortcodes. + */ + getShortcodes( text ) { + if ( typeof text !== "string" ) { + console.error( "Failed to get shortcodes. Expected parameter to be a string, instead received" + typeof text ); + return false; + } + + const captures = this.matchCapturingShortcodes( text ); + + // Remove the capturing shortcodes from the text before trying to match the non-capturing shortcodes. + captures.forEach( capture => { + text = text.replace( capture, "" ); + } ); + + const nonCaptures = this.matchNonCapturingShortcodes( text ); + + return captures.concat( nonCaptures ); + } + + /** + * Matches the capturing shortcodes from a given piece of text. + * + * @param {string} text Text to get the capturing shortcodes from. + * + * @returns {Array} The capturing shortcodes. + */ + matchCapturingShortcodes( text ) { + // First identify which tags are being used in a capturing shortcode by looking for closing tags. + const captureShortcodes = ( text.match( this.closingTagRegex ) || [] ).join( " " ).match( this.shortcodesRegex ) || []; + + return flatten( captureShortcodes.map( captureShortcode => { + const captureRegex = "\\[" + captureShortcode + "[^\\]]*?\\].*?\\[\\/" + captureShortcode + "\\]"; + return text.match( new RegExp( captureRegex, "g" ) ) || []; + } ) ); + } + + /** + * Matches the non-capturing shortcodes from a given piece of text. + * + * @param {string} text Text to get the non-capturing shortcodes from. + * + * @returns {Array} The non-capturing shortcodes. + */ + matchNonCapturingShortcodes( text ) { + return text.match( this.nonCaptureRegex ) || []; + } + + /** + * Parses the unparsed shortcodes through AJAX and clears them. + * + * @param {Array} shortcodes shortcodes to be parsed. + * @param {function} callback function to be called in the context of the AJAX callback. + * + * @returns {boolean|function} The callback function or false if no function has been supplied. + */ + parseShortcodes( shortcodes, callback ) { + if ( typeof callback !== "function" ) { + console.error( "Failed to parse shortcodes. Expected parameter to be a function, instead received " + typeof callback ); + return false; + } + + if ( typeof shortcodes === "object" && shortcodes.length > 0 ) { + jQuery.post( + ajaxurl, + { + action: "wpseo_filter_shortcodes", + _wpnonce: wpseoScriptData.analysis.plugins.shortcodes.wpseo_filter_shortcodes_nonce, + data: shortcodes, + }, + function( shortcodeResults ) { + this.saveParsedShortcodes( shortcodeResults, callback ); + }.bind( this ) + ); + } else { + return callback(); + } + } + + /** + * Saves the shortcodes that were parsed to `this.parsedShortcodes`, and then call the callback function. + * + * @param {string} shortcodeResults Shortcodes that must be saved. This is the response from the AJAX request. + * @param {function} callback Callback to execute after saving shortcodes. + * + * @returns {void} + */ + saveParsedShortcodes( shortcodeResults, callback ) { + // Parse the stringified shortcode results to an array. + const shortcodes = JSON.parse( shortcodeResults ); + + // Push the shortcodes to the array of parsed shortcodes. + this.parsedShortcodes.push( ...shortcodes ); + + callback(); + } +} + +export default YoastShortcodePlugin; + +const { + updateShortcodesForParsing, +} = actions; + +/** + * Initializes the shortcode plugin. + * + * @param {App} app The app object. + * @param {Object} store The redux store. + * + * @returns {void} + */ +export function initShortcodePlugin( app, store ) { + let shortcodesToBeParsed = []; + shortcodesToBeParsed = applyFilters( "yoast.analysis.shortcodes", shortcodesToBeParsed ); + + // Make sure the added shortcodes are valid. They are valid if they are included in `wpseo_shortcode_tags`. + const validShortcodes = wpseoScriptData.analysis.plugins.shortcodes.wpseo_shortcode_tags; + shortcodesToBeParsed = shortcodesToBeParsed.filter( shortcode => validShortcodes.includes( shortcode ) ); + + // Parses the shortcodes when `shortcodesToBeParsed` is provided. + if ( shortcodesToBeParsed.length > 0 ) { + store.dispatch( updateShortcodesForParsing( shortcodesToBeParsed ) ); + + window.YoastSEO.wp.shortcodePlugin = new YoastShortcodePlugin( { + registerPlugin: app.registerPlugin, + registerModification: app.registerModification, + pluginReady: app.pluginReady, + pluginReloaded: app.pluginReloaded, + }, shortcodesToBeParsed ); + } +} diff --git a/packages/js/src/components/contentAnalysis/Results.js b/packages/js/src/components/contentAnalysis/Results.js index c010d4c07d7..c92a0d90ce2 100644 --- a/packages/js/src/components/contentAnalysis/Results.js +++ b/packages/js/src/components/contentAnalysis/Results.js @@ -263,6 +263,12 @@ class Results extends Component { const labels = Object.assign( defaultLabels, resultCategoryLabels ); + let marksButtonStatus = this.props.marksButtonStatus; + // If the marks are enabled, but we are also parsing shortcodes, disable the markers. + if ( marksButtonStatus === "enabled" && this.props.shortcodesForParsing.length > 0 ) { + marksButtonStatus = "disabled"; + } + return ( { diff --git a/packages/js/src/initializers/post-scraper.js b/packages/js/src/initializers/post-scraper.js index 80eb4102857..eb625f613b7 100644 --- a/packages/js/src/initializers/post-scraper.js +++ b/packages/js/src/initializers/post-scraper.js @@ -2,16 +2,14 @@ // External dependencies. import { App } from "yoastseo"; -import { - isUndefined, - debounce, -} from "lodash"; +import { debounce, isUndefined } from "lodash"; import { isShallowEqualObjects } from "@wordpress/is-shallow-equal"; import { select, subscribe } from "@wordpress/data"; // Internal dependencies. import YoastReplaceVarPlugin from "../analysis/plugins/replacevar-plugin"; import YoastReusableBlocksPlugin from "../analysis/plugins/reusable-blocks-plugin"; +import YoastShortcodePlugin, { initShortcodePlugin } from "../analysis/plugins/shortcode-plugin"; import YoastMarkdownPlugin from "../analysis/plugins/markdown-plugin"; import * as tinyMCEHelper from "../lib/tinymce"; import CompatibilityHelper from "../compatibility/compatibilityHelper"; @@ -52,7 +50,6 @@ import { actions } from "@yoast/externals/redux"; // Helper dependencies. import isBlockEditor from "../helpers/isBlockEditor"; - const { setFocusKeyword, setMarkerStatus, @@ -65,6 +62,7 @@ const { // Plugin class prototypes (not the instances) are being used by other plugins from the window. window.YoastReplaceVarPlugin = YoastReplaceVarPlugin; +window.YoastShortcodePlugin = YoastShortcodePlugin; /** * @summary Initializes the post scraper script. @@ -85,7 +83,6 @@ export default function initPostScraper( $, store, editorData ) { let postDataCollector; const customAnalysisData = new CustomAnalysisData(); - /** * Retrieves either a generated slug or the page title as slug for the preview. * @@ -134,7 +131,7 @@ export default function initPostScraper( $, store, editorData ) { * @returns {boolean} True when markers should be shown. */ function displayMarkers() { - return ! isBlockEditor() && wpseoScriptData.metabox.show_markers === "1"; + return ! isBlockEditor() && wpseoScriptData.metabox.show_markers; } /** @@ -369,7 +366,6 @@ export default function initPostScraper( $, store, editorData ) { /** * Handles page builder compatibility, regarding the marker buttons. - * * @returns {void} */ function handlePageBuilderCompatibility() { @@ -432,6 +428,7 @@ export default function initPostScraper( $, store, editorData ) { tinyMCEHelper.setStore( store ); tinyMCEHelper.wpTextViewOnInitCheck(); + handlePageBuilderCompatibility(); // Avoid error when snippet metabox is not rendered. @@ -494,6 +491,7 @@ export default function initPostScraper( $, store, editorData ) { // Analysis plugins window.YoastSEO.wp = {}; window.YoastSEO.wp.replaceVarsPlugin = new YoastReplaceVarPlugin( app, store ); + initShortcodePlugin( app, store ); if ( isBlockEditor() ) { const reusableBlocksPlugin = new YoastReusableBlocksPlugin( app.registerPlugin, app.registerModification, window.YoastSEO.app.refresh ); diff --git a/packages/js/src/initializers/term-scraper.js b/packages/js/src/initializers/term-scraper.js index 425ae758753..00478ca506e 100644 --- a/packages/js/src/initializers/term-scraper.js +++ b/packages/js/src/initializers/term-scraper.js @@ -13,6 +13,7 @@ import { termsTmceId } from "../lib/tinymce"; import Pluggable from "../lib/Pluggable"; import requestWordsToHighlight from "../analysis/requestWordsToHighlight.js"; import YoastReplaceVarPlugin from "../analysis/plugins/replacevar-plugin"; +import YoastShortcodePlugin, { initShortcodePlugin } from "../analysis/plugins/shortcode-plugin"; // UI dependencies. import { update as updateTrafficLight } from "../ui/trafficLight"; @@ -56,6 +57,7 @@ window.yoastHideMarkers = true; // Plugin class prototypes (not the instances) are being used by other plugins from the window. window.YoastReplaceVarPlugin = YoastReplaceVarPlugin; +window.YoastShortcodePlugin = YoastShortcodePlugin; /** * @summary Initializes the term scraper script. @@ -372,6 +374,7 @@ export default function initTermScraper( $, store, editorData ) { // Init Plugins. window.YoastSEO.wp = {}; window.YoastSEO.wp.replaceVarsPlugin = new YoastReplaceVarPlugin( app, store ); + initShortcodePlugin( app, store ); // For backwards compatibility. window.YoastSEO.analyzerArgs = args; diff --git a/packages/js/src/lib/tinymce.js b/packages/js/src/lib/tinymce.js index e593cf22010..013ede23d20 100644 --- a/packages/js/src/lib/tinymce.js +++ b/packages/js/src/lib/tinymce.js @@ -15,14 +15,14 @@ let store; * * @type {string} */ -export var tmceId = "content"; +export const tmceId = "content"; /** * The HTML 'id' attribute for the tinyMCE editor on the edit term page. * * @type {string} */ -export var termsTmceId = "description"; +export const termsTmceId = "description"; /** * Sets the store. @@ -46,7 +46,7 @@ function tinyMCEElementContent( contentID ) { } /** - * Returns whether or not the tinyMCE script is available on the page. + * Returns whether the tinyMCE script is available on the page. * * @returns {boolean} True when tinyMCE is loaded. */ @@ -73,7 +73,7 @@ function isTinyMCEBodyAvailable( editorID ) { } /** - * Returns whether or not a tinyMCE editor with the given ID is available. + * Returns whether a tinyMCE editor with the given ID is available. * * @param {string} editorID The ID of the tinyMCE editor. * @@ -84,7 +84,7 @@ export function isTinyMCEAvailable( editorID ) { return false; } - var editor = tinyMCE.get( editorID ); + const editor = tinyMCE.get( editorID ); return ( editor !== null && ! editor.isHidden() @@ -98,7 +98,7 @@ export function isTinyMCEAvailable( editorID ) { */ export function getContentTinyMce( contentID ) { // If no TinyMCE object available - var content = ""; + let content = ""; if ( isTinyMCEAvailable( contentID ) === false || isTinyMCEBodyAvailable( contentID ) === false ) { content = tinyMCEElementContent( contentID ); } else { @@ -122,7 +122,7 @@ export function addEventHandler( editorId, events, callback ) { } tinyMCE.on( "addEditor", function( evt ) { - var editor = evt.editor; + const editor = evt.editor; if ( editor.id !== editorId ) { return; diff --git a/packages/js/src/redux/actions/analysis.js b/packages/js/src/redux/actions/analysis.js index 6ba63dc0aba..3665431bf9a 100644 --- a/packages/js/src/redux/actions/analysis.js +++ b/packages/js/src/redux/actions/analysis.js @@ -1,5 +1,6 @@ export const UPDATE_SNIPPET_DATA = "SNIPPET_EDITOR_UPDATE_ANALYSIS_DATA"; export const RUN_ANALYSIS = "RUN_ANALYSIS"; +export const UPDATE_SHORTCODES_FOR_PARSING = "UPDATE_SHORTCODES_FOR_PARSING"; /** * Updates the analysis data in redux. @@ -26,3 +27,17 @@ export function runAnalysis() { timestamp: Date.now(), }; } + +/** + * Updates the list of shortcodes for parsing. + * + * @param {Array} shortcodes The list of shortcodes for parsing. + * + * @returns {Object} An action to dispatch. + */ +export function updateShortcodesForParsing( shortcodes ) { + return { + type: UPDATE_SHORTCODES_FOR_PARSING, + shortcodesForParsing: shortcodes, + }; +} diff --git a/packages/js/src/redux/reducers/analysisData.js b/packages/js/src/redux/reducers/analysisData.js index f24dc98d83c..7de5b53bbea 100644 --- a/packages/js/src/redux/reducers/analysisData.js +++ b/packages/js/src/redux/reducers/analysisData.js @@ -1,8 +1,9 @@ -import { UPDATE_SNIPPET_DATA, RUN_ANALYSIS } from "../actions"; +import { UPDATE_SNIPPET_DATA, RUN_ANALYSIS, UPDATE_SHORTCODES_FOR_PARSING } from "../actions"; const INITIAL_STATE = { snippet: {}, timestamp: 0, + shortcodesForParsing: [], }; /** @@ -25,6 +26,11 @@ function analysisDataReducer( state = INITIAL_STATE, action ) { ...state, timestamp: action.timestamp, }; + case UPDATE_SHORTCODES_FOR_PARSING: + return { + ...state, + shortcodesForParsing: action.shortcodesForParsing, + }; } return state; diff --git a/packages/js/src/redux/selectors/analysis.js b/packages/js/src/redux/selectors/analysis.js index 72443b4a994..392f64747cc 100644 --- a/packages/js/src/redux/selectors/analysis.js +++ b/packages/js/src/redux/selectors/analysis.js @@ -42,6 +42,14 @@ export const getPermalink = ( state ) => get( state, "analysisData.snippet.url", */ export const getAnalysisTimestamp = ( state ) => parseInt( get( state, "analysisData.timestamp", 0 ), 10 ); +/** + * Gets the list of shortcodes that will be parsed. + * + * @param {Object} state The state. + * @returns {string[]} And array of shortcodes that will be parsed. + */ +export const getShortcodesForParsing = ( state ) => get( state, "analysisData.shortcodesForParsing", [] ); + /** * Gets the analysis data. * diff --git a/packages/js/tests/analysis/plugins/shortcode-plugin.test.js b/packages/js/tests/analysis/plugins/shortcode-plugin.test.js new file mode 100644 index 00000000000..3e291423538 --- /dev/null +++ b/packages/js/tests/analysis/plugins/shortcode-plugin.test.js @@ -0,0 +1,157 @@ +import YoastShortcodePlugin from "../../../src/analysis/plugins/shortcode-plugin"; + +describe( "YoastShortcodePlugin", () => { + let plugin; + // Mock the functions and objects needed for the class. + const mockRegisterPlugin = jest.fn(); + const mockRegisterModification = jest.fn(); + const mockPluginReady = jest.fn(); + const mockPluginReloaded = jest.fn(); + const shortcodesToBeParsed = [ "caption", "wpseo_breadcrumb" ]; + + global.tinyMCE = {}; + // eslint-disable-next-line camelcase + global.wpseoScriptData = { analysis: { plugins: { shortcodes: { wpseo_filter_shortcodes_nonce: "nonce" } } } }; + global.ajaxurl = "http://example.com/ajax"; + + jest.mock( "lodash", () => ( { + ...jest.requireActual( "lodash" ), + debounce: jest.fn( fn => fn ), + } ) ); + + beforeEach( () => { + plugin = new YoastShortcodePlugin( + { + registerPlugin: mockRegisterPlugin, + registerModification: mockRegisterModification, + pluginReady: mockPluginReady, + pluginReloaded: mockPluginReloaded, + }, + shortcodesToBeParsed + ); + } ); + + afterEach( () => { + jest.clearAllMocks(); + } ); + + it( "should initialize YoastShortcodePlugin and register it", () => { + expect( mockRegisterPlugin ).toHaveBeenCalledWith( "YoastShortcodePlugin", { status: "loading" } ); + } ); + + it( "should declare the YoastShortcodePlugin ready", () => { + plugin.declareReady(); + + expect( mockPluginReady ).toHaveBeenCalledWith( "YoastShortcodePlugin" ); + } ); + + it( "should declare the YoastShortcodePlugin reloaded", () => { + plugin.declareReloaded(); + + expect( mockPluginReloaded ).toHaveBeenCalledWith( "YoastShortcodePlugin" ); + } ); + + it( "should register the modifications", () => { + plugin.registerModifications(); + + expect( mockRegisterModification ).toHaveBeenCalledWith( "content", expect.any( Function ), "YoastShortcodePlugin" ); + } ); + + it( "should return true if the shortcode is unparsed", () => { + expect( plugin.isUnparsedShortcode( "caption" ) ).toBeTruthy(); + } ); + + it( "should return the unparsed shortcodes", () => { + expect( plugin.getUnparsedShortcodes( [ "caption", "wpseo_breadcrumb" ] ) ).toEqual( [ "caption", "wpseo_breadcrumb" ] ); + } ); + + it( "should replace parsed shortcodes in the content", () => { + const content = "[wpseo_breadcrumb]

A paragraph

"; + plugin.parsedShortcodes = [ + { shortcode: "[wpseo_breadcrumb]", output: "Home » shortcodes" }, + ]; + const result = plugin.replaceShortcodes( content ); + expect( result ).toEqual( "Home » shortcodes

A paragraph

" ); + } ); + + it( "should match and return capturing shortcodes from the content", () => { + const inputText = "This is a sample [wpseo_breadcrumb] text with an image with caption [caption id=\"attachment_8\" align=\"alignnone\"" + + " width=\"230\"]A tortie cat, not wayang kulit" + + " A tortie cat, not a red panda.[/caption]."; + const capturingShortcodes = plugin.matchCapturingShortcodes( inputText ); + expect( capturingShortcodes ).toEqual( [ + "[caption id=\"attachment_8\" align=\"alignnone\" width=\"230\"]" + + "A tortie cat, " +
+			"not wayang kulit A tortie cat, not a red panda.[/caption]", + ] ); + } ); + + it( "should return an empty array if no match is found for the capturing shortcodes", () => { + const inputText = "This is a sample text without shortcodes"; + const capturingShortcodes = plugin.matchCapturingShortcodes( inputText ); + expect( capturingShortcodes ).toEqual( [] ); + } ); + + it( "should match and return non-capturing shortcodes from the content", () => { + const inputText = "This is a sample [wpseo_breadcrumb] text with an image with caption [caption id=\"attachment_8\" align=\"alignnone\"" + + " width=\"230\"]A tortie cat, not wayang kulit" + + " A tortie cat, not a red panda.[/caption]."; + const capturingShortcodes = plugin.matchNonCapturingShortcodes( inputText ); + expect( capturingShortcodes ).toEqual( [ + "[wpseo_breadcrumb]", + "[caption id=\"attachment_8\" align=\"alignnone\" width=\"230\"]", + ] ); + } ); + + it( "should extract shortcodes from a given piece of text", () => { + // Test input text with shortcodes. + const inputText = "This is a sample [wpseo_breadcrumb] text with an image with caption [caption id=\"attachment_8\" align=\"alignnone\"" + + " width=\"230\"]A tortie cat, not wayang kulit" + + " A tortie cat, not a red panda.[/caption]."; + + // Call the getShortcodes method. + const shortcodes = plugin.getShortcodes( inputText ); + + // Expect the extracted shortcodes. + expect( shortcodes ).toEqual( [ + "[caption id=\"attachment_8\" align=\"alignnone\" " + + "width=\"230\"]A tortie cat, not wayang kulit" + + " A tortie cat, not a red panda.[/caption]", + "[wpseo_breadcrumb]" ] ); + } ); + + it( "should parse shortcodes through AJAX and save parsed shortcodes to `parsedShortcodes` array", ( done ) => { + // Simulate an AJAX response with parsed shortcodes + const shortcodeResults = JSON.stringify( [ + { shortcode: "[shortcode1]", output: "Parsed Output 1" }, + { shortcode: "[shortcode2]", output: "Parsed Output 2" }, + ] ); + // Mock the jQuery.post function. + global.jQuery = { + post: jest.fn( ( url, data, callback ) => { + callback( shortcodeResults ); + } ), + }; + + // Mock the saveParsedShortcodes method for assertions. + const saveParsedShortcodes = jest.spyOn( plugin, "saveParsedShortcodes" ); + + // Call the parseShortcodes method. + plugin.parseShortcodes( [ "[shortcode1]", "[shortcode2]" ], () => { + // Expect that saveParsedShortcodes was called with the correct parsed shortcodes. + expect( saveParsedShortcodes ).toHaveBeenCalledWith( shortcodeResults, expect.any( Function ) ); + // Expect that shortcode results are saved in `parsedShortcodes` array. + expect( plugin.parsedShortcodes ).toEqual( [ + { shortcode: "[shortcode1]", output: "Parsed Output 1" }, + { shortcode: "[shortcode2]", output: "Parsed Output 2" }, + ] ); + + done(); + } ); + } ); +} ); + diff --git a/packages/js/tests/redux/reducers/analysisData.test.js b/packages/js/tests/redux/reducers/analysisData.test.js index 4767d6f4884..66747335de2 100644 --- a/packages/js/tests/redux/reducers/analysisData.test.js +++ b/packages/js/tests/redux/reducers/analysisData.test.js @@ -1,11 +1,11 @@ -import { updateAnalysisData } from "../../../src/redux/actions/analysis"; +import { updateAnalysisData, updateShortcodesForParsing } from "../../../src/redux/actions/analysis"; import analysisDataReducer from "../../../src/redux/reducers/analysisData"; describe( "Analysis data reducer", () => { it( "has a default state", () => { const result = analysisDataReducer( undefined, { type: "undefined" } ); - expect( result ).toEqual( { snippet: {}, timestamp: 0 } ); + expect( result ).toEqual( { snippet: {}, timestamp: 0, shortcodesForParsing: [] } ); } ); it( "ignores unrecognized actions", () => { @@ -24,4 +24,13 @@ describe( "Analysis data reducer", () => { expect( result ).toEqual( expected ); } ); + + it( "updates the shortcodes for parsing", () => { + const action = updateShortcodesForParsing( [ "wpseo_breadcrumb" ] ); + const expected = { shortcodesForParsing: [ "wpseo_breadcrumb" ] }; + + const result = analysisDataReducer( {}, action ); + + expect( result ).toEqual( expected ); + } ); } ); diff --git a/packages/yoastseo/src/pluggable.js b/packages/yoastseo/src/pluggable.js index f953327980f..fcb32fd0c2d 100644 --- a/packages/yoastseo/src/pluggable.js +++ b/packages/yoastseo/src/pluggable.js @@ -4,6 +4,9 @@ import InvalidTypeError from "./errors/invalidType"; /** * The plugins object takes care of plugin registrations, preloading and managing data modifications. * + * Please note that there is a newer copy of this plugin in `packages/js/src/lib/Pluggable.js`. + * For internal use, please use the newer copy for all interfaces except for registering assessments. + * * A plugin for YoastSEO.js is basically a piece of JavaScript that hooks into YoastSEO.js by registering modifications. * In order to do so, it must first register itself as a plugin with YoastSEO.js. To keep our content analysis fast, we * don't allow asynchronous modifications. That's why we require plugins to preload all data they need in order to modify diff --git a/src/integrations/third-party/elementor.php b/src/integrations/third-party/elementor.php index f5dd067a656..9b98b153cab 100644 --- a/src/integrations/third-party/elementor.php +++ b/src/integrations/third-party/elementor.php @@ -434,7 +434,8 @@ public function enqueue() { 'has_taxonomies' => $this->current_post_type_has_taxonomies(), ], 'shortcodes' => [ - 'wpseo_shortcode_tags' => $this->get_valid_shortcode_tags(), + 'wpseo_shortcode_tags' => $this->get_valid_shortcode_tags(), + 'wpseo_filter_shortcodes_nonce' => \wp_create_nonce( 'wpseo-filter-shortcodes' ), ], ];