yoastseo-dep
Version:
Yoast clientside page analysis
378 lines (330 loc) • 13.9 kB
JavaScript
import { forEach, isObject, isString, isUndefined, reduce } from "lodash-es";
import InvalidTypeError from "./errors/invalidType";
/**
* The plugins object takes care of plugin registrations, preloading and managing data modifications.
*
* 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
* the content. If plugins need to preload data, they can first register, then preload using AJAX and call `ready` once
* preloaded.
*
* To minimize client side memory usage, we request plugins to preload as little data as possible. If you need to dynamically
* fetch more data in the process of content creation, you can reload your data set and let YoastSEO.js know you've reloaded
* by calling `reloaded`.
*/
/**
* Setup Pluggable and set its default values.
*
* @constructor
* @param {App} app The App object to attach to.
* @property {number} preloadThreshold The maximum time plugins are allowed to preload before we load our content analysis.
* @property {object} plugins The plugins that have been registered.
* @property {object} modifications The modifications that have been registered. Every modification contains an array with callables.
* @property {Array} customTests All tests added by plugins.
*/
var Pluggable = function( app ) {
this.app = app;
this.loaded = false;
this.preloadThreshold = 3000;
this.plugins = {};
this.modifications = {};
this.customTests = [];
// Allow plugins 1500 ms to register before we start polling their
setTimeout( this._pollLoadingPlugins.bind( this ), 1500 );
};
// ***** DSL IMPLEMENTATION ***** //
/**
* Register a plugin with YoastSEO. A plugin can be declared "ready" right at registration or later using `this.ready`.
*
* @param {string} pluginName The name of the plugin to be registered.
* @param {object} options The options passed by the plugin.
* @param {string} options.status The status of the plugin being registered. Can either be "loading" or "ready".
* @returns {boolean} Whether or not the plugin was successfully registered.
*/
Pluggable.prototype._registerPlugin = function( pluginName, options ) {
if ( typeof pluginName !== "string" ) {
console.error( "Failed to register plugin. Expected parameter `pluginName` to be a string." );
return false;
}
if ( ! isUndefined( options ) && typeof options !== "object" ) {
console.error( "Failed to register plugin " + pluginName + ". Expected parameters `options` to be a object." );
return false;
}
if ( this._validateUniqueness( pluginName ) === false ) {
console.error( "Failed to register plugin. Plugin with name " + pluginName + " already exists" );
return false;
}
this.plugins[ pluginName ] = options;
return true;
};
/**
* Declare a plugin "ready". Use this if you need to preload data with AJAX.
*
* @param {string} pluginName The name of the plugin to be declared as ready.
* @returns {boolean} Whether or not the plugin was successfully declared ready.
*/
Pluggable.prototype._ready = function( pluginName ) {
if ( typeof pluginName !== "string" ) {
console.error( "Failed to modify status for plugin " + pluginName + ". Expected parameter `pluginName` to be a string." );
return false;
}
if ( isUndefined( this.plugins[ pluginName ] ) ) {
console.error( "Failed to modify status for plugin " + pluginName + ". The plugin was not properly registered." );
return false;
}
this.plugins[ pluginName ].status = "ready";
return true;
};
/**
* Used to declare a plugin has been reloaded. If an analysis is currently running. We will reset it to ensure running the latest modifications.
*
* @param {string} pluginName The name of the plugin to be declared as reloaded.
* @returns {boolean} Whether or not the plugin was successfully declared as reloaded.
*/
Pluggable.prototype._reloaded = function( pluginName ) {
if ( typeof pluginName !== "string" ) {
console.error( "Failed to reload Content Analysis for " + pluginName + ". Expected parameter `pluginName` to be a string." );
return false;
}
if ( isUndefined( this.plugins[ pluginName ] ) ) {
console.error( "Failed to reload Content Analysis for plugin " + pluginName + ". The plugin was not properly registered." );
return false;
}
this.app.refresh();
return true;
};
/**
* Enables hooking a callable to a specific data filter supported by YoastSEO. Can only be performed for plugins that have finished loading.
*
* @param {string} modification The name of the filter
* @param {function} callable The callable
* @param {string} pluginName The plugin that is registering the modification.
* @param {number} priority (optional) Used to specify the order in which the callables associated with a particular filter are called.
* Lower numbers correspond with earlier execution.
* @returns {boolean} Whether or not applying the hook was successfull.
*/
Pluggable.prototype._registerModification = function( modification, callable, pluginName, priority ) {
if ( typeof modification !== "string" ) {
console.error( "Failed to register modification for plugin " + pluginName + ". Expected parameter `modification` to be a string." );
return false;
}
if ( typeof callable !== "function" ) {
console.error( "Failed to register modification for plugin " + pluginName + ". Expected parameter `callable` to be a function." );
return false;
}
if ( typeof pluginName !== "string" ) {
console.error( "Failed to register modification for plugin " + pluginName + ". Expected parameter `pluginName` to be a string." );
return false;
}
// Validate origin
if ( this._validateOrigin( pluginName ) === false ) {
console.error( "Failed to register modification for plugin " + pluginName + ". The integration has not finished loading yet." );
return false;
}
// Default priority to 10
var prio = typeof priority === "number" ? priority : 10;
var callableObject = {
callable: callable,
origin: pluginName,
priority: prio,
};
// Make sure modification is defined on modifications object
if ( isUndefined( this.modifications[ modification ] ) ) {
this.modifications[ modification ] = [];
}
this.modifications[ modification ].push( callableObject );
return true;
};
/**
* Register test for a specific plugin
*
* @returns {void}
*
* @deprecated
*/
Pluggable.prototype._registerTest = function() {
console.error( "This function is deprecated, please use _registerAssessment" );
};
/**
* Register an assessment for a specific plugin
*
* @param {object} assessor The assessor object where the assessments needs to be added.
* @param {string} name The name of the assessment.
* @param {function} assessment The function to run as an assessment.
* @param {string} pluginName The name of the plugin associated with the assessment.
* @returns {boolean} Whether registering the assessment was successful.
* @private
*/
Pluggable.prototype._registerAssessment = function( assessor, name, assessment, pluginName ) {
if ( ! isString( name ) ) {
throw new InvalidTypeError( "Failed to register test for plugin " + pluginName + ". Expected parameter `name` to be a string." );
}
if ( ! isObject( assessment ) ) {
throw new InvalidTypeError( "Failed to register assessment for plugin " + pluginName +
". Expected parameter `assessment` to be a function." );
}
if ( ! isString( pluginName ) ) {
throw new InvalidTypeError( "Failed to register assessment for plugin " + pluginName +
". Expected parameter `pluginName` to be a string." );
}
// Prefix the name with the pluginName so the test name is always unique.
name = pluginName + "-" + name;
assessor.addAssessment( name, assessment );
return true;
};
// ***** PRIVATE HANDLERS *****//
/**
* Poller to handle loading of plugins. Plugins can register with our app to let us know they are going to hook into our Javascript. They are allowed
* 5 seconds of pre-loading time to fetch all the data they need to be able to perform their data modifications. We will only apply data modifications
* from plugins that have declared ready within the pre-loading time in order to safeguard UX and data integrity.
*
* @param {number} pollTime (optional) The accumulated time to compare with the pre-load threshold.
* @returns {void}
* @private
*/
Pluggable.prototype._pollLoadingPlugins = function( pollTime ) {
pollTime = isUndefined( pollTime ) ? 0 : pollTime;
if ( this._allReady() === true ) {
this.loaded = true;
this.app.pluginsLoaded();
} else if ( pollTime >= this.preloadThreshold ) {
this._pollTimeExceeded();
} else {
pollTime += 50;
setTimeout( this._pollLoadingPlugins.bind( this, pollTime ), 50 );
}
};
/**
* Checks if all registered plugins have finished loading
*
* @returns {boolean} Whether or not all registered plugins are loaded.
* @private
*/
Pluggable.prototype._allReady = function() {
return reduce( this.plugins, function( allReady, plugin ) {
return allReady && plugin.status === "ready";
}, true );
};
/**
* Removes the plugins that were not loaded within time and calls `pluginsLoaded` on the app.
*
* @returns {void}
* @private
*/
Pluggable.prototype._pollTimeExceeded = function() {
forEach( this.plugins, function( plugin, pluginName ) {
if ( ! isUndefined( plugin.options ) && plugin.options.status !== "ready" ) {
console.error( "Error: Plugin " + pluginName + ". did not finish loading in time." );
delete this.plugins[ pluginName ];
}
} );
this.loaded = true;
this.app.pluginsLoaded();
};
/**
* Calls the callables added to a modification hook. See the YoastSEO.js Readme for a list of supported modification hooks.
*
* @param {string} modification The name of the filter
* @param {*} data The data to filter
* @param {*} context (optional) Object for passing context parameters to the callable.
* @returns {*} The filtered data
* @private
*/
Pluggable.prototype._applyModifications = function( modification, data, context ) {
var callChain = this.modifications[ modification ];
if ( callChain instanceof Array && callChain.length > 0 ) {
callChain = this._stripIllegalModifications( callChain );
callChain.sort( function( a, b ) {
return a.priority - b.priority;
} );
forEach( callChain, function( callableObject ) {
var callable = callableObject.callable;
var newData = callable( data, context );
if ( typeof newData === typeof data ) {
data = newData;
} else {
console.error( "Modification with name " + modification + " performed by plugin with name " +
callableObject.origin +
" was ignored because the data that was returned by it was of a different type than the data we had passed it." );
}
} );
}
return data;
};
/**
* Adds new tests to the analyzer and it's scoring object.
*
* @param {YoastSEO.Analyzer} analyzer The analyzer object to add the tests to
* @returns {void}
* @private
*/
Pluggable.prototype._addPluginTests = function( analyzer ) {
this.customTests.map( function( customTest ) {
this._addPluginTest( analyzer, customTest );
}, this );
};
/**
* Adds one new test to the analyzer and it's scoring object.
*
* @param {YoastSEO.Analyzer} analyzer The analyzer that the test will be added to.
* @param {Object} pluginTest The test to be added.
* @param {string} pluginTest.name The name of the test.
* @param {function} pluginTest.callable The function associated with the test.
* @param {function} pluginTest.analysis The function associated with the analyzer.
* @param {Object} pluginTest.scoring The scoring object to be used.
* @returns {void}
* @private
*/
Pluggable.prototype._addPluginTest = function( analyzer, pluginTest ) {
analyzer.addAnalysis( {
name: pluginTest.name,
callable: pluginTest.analysis,
} );
analyzer.analyzeScorer.addScoring( {
name: pluginTest.name,
scoring: pluginTest.scoring,
} );
};
/**
* Strips modifications from a callChain if they were not added with a valid origin.
*
* @param {Array} callChain The callChain that contains items with possible invalid origins.
* @returns {Array} callChain The stripped version of the callChain.
* @private
*/
Pluggable.prototype._stripIllegalModifications = function( callChain ) {
forEach( callChain, function( callableObject, index ) {
if ( this._validateOrigin( callableObject.origin ) === false ) {
delete callChain[ index ];
}
}.bind( this ) );
return callChain;
};
/**
* Validates if origin of a modification has been registered and finished preloading.
*
* @param {string} pluginName The name of the plugin that needs to be validated.
* @returns {boolean} Whether or not the origin is valid.
* @private
*/
Pluggable.prototype._validateOrigin = function( pluginName ) {
if ( this.plugins[ pluginName ].status !== "ready" ) {
return false;
}
return true;
};
/**
* Validates if registered plugin has a unique name.
*
* @param {string} pluginName The name of the plugin that needs to be validated for uniqueness.
* @returns {boolean} Whether or not the plugin has a unique name.
* @private
*/
Pluggable.prototype._validateUniqueness = function( pluginName ) {
if ( ! isUndefined( this.plugins[ pluginName ] ) ) {
return false;
}
return true;
};
export default Pluggable;