Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Provide a filter to parse shortcodes #20812

Merged
merged 41 commits into from
Nov 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
d314a17
Add back the shortcode plugin
FAMarfuaty Oct 31, 2023
04ef6cb
Parses the provided shortcodes
FAMarfuaty Oct 31, 2023
6f7abcf
Add back `wpseo_filter_shortcodes_nonce`
FAMarfuaty Oct 31, 2023
eeceb9d
Merge branch 'trunk' of github.com:Yoast/wordpress-seo into 20801-inc…
FAMarfuaty Oct 31, 2023
5724011
adapt code
FAMarfuaty Oct 31, 2023
26b7c5a
simplify code
FAMarfuaty Nov 1, 2023
2604b55
Merge branch 'trunk' of github.com:Yoast/wordpress-seo into 20801-inc…
FAMarfuaty Nov 1, 2023
ae91b2c
Add initial unit tests
FAMarfuaty Nov 2, 2023
a950aaf
Add more unit tests
FAMarfuaty Nov 2, 2023
4a1c30f
Add more unit tests
FAMarfuaty Nov 3, 2023
42b4afa
simplify tests
FAMarfuaty Nov 3, 2023
e43911b
Add logs
FAMarfuaty Nov 3, 2023
95851dc
Add more unit tests
FAMarfuaty Nov 3, 2023
0c0cee9
use global function
FAMarfuaty Nov 6, 2023
efe2df8
Pass the mark button status
FAMarfuaty Nov 6, 2023
f65ab9b
Disable the mark button when the filter is used
FAMarfuaty Nov 6, 2023
8020be7
remove console.log
FAMarfuaty Nov 6, 2023
23ddc3c
Add shortcodes for parsing to the store
FAMarfuaty Nov 8, 2023
0f20e5d
Disable the highlighting button when there are shortcodes to be parsed
FAMarfuaty Nov 8, 2023
298ca1a
Update the shortcodes for parsing in the store
FAMarfuaty Nov 8, 2023
5fcf2a9
Adapt code
FAMarfuaty Nov 8, 2023
60c0630
Add unit test
FAMarfuaty Nov 8, 2023
dccecba
Merge branch 'trunk' of github.com:Yoast/wordpress-seo into 20801-inc…
FAMarfuaty Nov 8, 2023
fbbef96
Merge branch 'trunk' of github.com:Yoast/wordpress-seo into 20801-inc…
FAMarfuaty Nov 9, 2023
faebc78
Adjust code comments
FAMarfuaty Nov 9, 2023
afc7ced
Only include valid shortcodes
FAMarfuaty Nov 9, 2023
0b2f853
Use the right Pluggable
FAMarfuaty Nov 9, 2023
6d8deba
Improve comments
FAMarfuaty Nov 10, 2023
eb3d087
Use the initialized pluggable plugin in post-scrapper if it's availa…
FAMarfuaty Nov 10, 2023
1861ab5
Watch the content including the applied modifications instead of just…
FAMarfuaty Nov 10, 2023
6c3e012
Improve naming
FAMarfuaty Nov 13, 2023
18ce522
Update packages/js/src/elementor/initializers/pluggable.js
FAMarfuaty Nov 14, 2023
acb4b61
Improve JSDoc
FAMarfuaty Nov 14, 2023
d4ff692
Improve code readability
FAMarfuaty Nov 14, 2023
8ab3c51
Merge branch '20801-incorrect-analysis-results-when-parsing-shortcode…
FAMarfuaty Nov 14, 2023
c3f6a11
Move the check for disabling the eye marker when there are available …
FAMarfuaty Nov 14, 2023
d5af3d0
Simplify code
FAMarfuaty Nov 14, 2023
0c03e16
Adapt unit test
FAMarfuaty Nov 14, 2023
551ba65
Merge remote-tracking branch 'origin/trunk' into 20801-incorrect-anal…
mhkuu Nov 16, 2023
c477e4c
Enables shortcode parsing for terms
mhkuu Nov 20, 2023
fe8e603
Don't set the markers to 'disabled' when they are 'hidden'
mhkuu Nov 20, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion admin/metabox/class-metabox.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' ),
],
];

Expand Down
3 changes: 2 additions & 1 deletion admin/taxonomy/class-taxonomy.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' => [
Expand Down
352 changes: 352 additions & 0 deletions packages/js/src/analysis/plugins/shortcode-plugin.js
mhkuu marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -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 );
}
}
Loading