2023-01-23 11:03:31 +01:00

535 lines
18 KiB
JavaScript

/**
* Validates blocks for AMP compatibility.
*
* This uses the REST API response from saving a page to find validation errors.
* If one exists for a block, it display it inline with a Notice component.
*/
/* exported ampBlockValidation */
/* global wp, _ */
var ampBlockValidation = ( function() { // eslint-disable-line no-unused-vars
'use strict';
var module = {
/**
* Data exported from server.
*
* @param {Object}
*/
data: {
i18n: {},
ampValidityRestField: '',
isSanitizationAutoAccepted: false
},
/**
* Name of the store.
*
* @param {string}
*/
storeName: 'amp/blockValidation',
/**
* Holds the last states which are used for comparisons.
*
* @param {Object}
*/
lastStates: {
noticesAreReset: false,
validationErrors: [],
blockOrder: [],
blockValidationErrors: {}
},
/**
* Boot module.
*
* @param {Object} data - Module data.
* @return {void}
*/
boot: function boot( data ) {
module.data = data;
wp.i18n.setLocaleData( module.data.i18n, 'amp' );
wp.hooks.addFilter(
'editor.BlockEdit',
'amp/add-notice',
module.conditionallyAddNotice,
99 // eslint-disable-line
);
module.store = module.registerStore();
wp.data.subscribe( module.handleValidationErrorsStateChange );
},
/**
* Register store.
*
* @return {Object} Store.
*/
registerStore: function registerStore() {
return wp.data.registerStore( module.storeName, {
reducer: function( _state, action ) {
var state = _state || {
blockValidationErrorsByClientId: {}
};
switch ( action.type ) {
case 'UPDATE_BLOCKS_VALIDATION_ERRORS':
return _.extend( {}, state, {
blockValidationErrorsByClientId: action.blockValidationErrorsByClientId
} );
default:
return state;
}
},
actions: {
updateBlocksValidationErrors: function( blockValidationErrorsByClientId ) {
return {
type: 'UPDATE_BLOCKS_VALIDATION_ERRORS',
blockValidationErrorsByClientId: blockValidationErrorsByClientId
};
}
},
selectors: {
getBlockValidationErrors: function( state, clientId ) {
return state.blockValidationErrorsByClientId[ clientId ] || [];
}
}
} );
},
/**
* Checks if AMP is enabled for this post.
*
* @return {boolean} Returns true when the AMP toggle is on; else, false is returned.
*/
isAMPEnabled: function isAMPEnabled() {
var meta = wp.data.select( 'core/editor' ).getEditedPostAttribute( 'meta' );
if ( meta && meta.amp_status && window.wpAmpEditor.possibleStati.includes( meta.amp_status ) ) {
return 'enabled' === meta.amp_status;
}
return window.wpAmpEditor.defaultStatus;
},
/**
* Checks if the validate errors state change handler should wait before processing.
*
* @return {boolean} Whether should wait.
*/
waitToHandleStateChange: function waitToHandleStateChange() {
var currentPost;
// @todo Gutenberg currently is not persisting isDirty state if changes are made during save request. Block order mismatch.
// We can only align block validation errors with blocks in editor when in saved state, since only here will the blocks be aligned with the validation errors.
if ( wp.data.select( 'core/editor' ).isEditedPostDirty() || ( ! wp.data.select( 'core/editor' ).isEditedPostDirty() && wp.data.select( 'core/editor' ).isEditedPostNew() ) ) {
return true;
}
// Wait for the current post to be set up.
currentPost = wp.data.select( 'core/editor' ).getCurrentPost();
if ( ! currentPost.hasOwnProperty( 'id' ) ) {
return true;
}
return false;
},
/**
* Handle state change regarding validation errors.
*
* This is essentially a JS implementation of \AMP_Validation_Manager::print_edit_form_validation_status() in PHP.
*
* @return {void}
*/
handleValidationErrorsStateChange: function handleValidationErrorsStateChange() {
var currentPost, validationErrors, blockValidationErrors, noticeOptions, noticeMessage, blockErrorCount, ampValidity, rejectedErrors;
if ( ! module.isAMPEnabled() ) {
if ( ! module.lastStates.noticesAreReset ) {
module.lastStates.validationErrors = [];
module.lastStates.noticesAreReset = true;
module.resetWarningNotice();
module.resetBlockNotices();
}
return;
}
if ( module.waitToHandleStateChange() ) {
return;
}
currentPost = wp.data.select( 'core/editor' ).getCurrentPost();
ampValidity = currentPost[ module.data.ampValidityRestField ] || {};
// Show all validation errors which have not been explicitly acknowledged as accepted.
validationErrors = _.map(
_.filter( ampValidity.results, function( result ) {
// @todo Show VALIDATION_ERROR_ACK_REJECTED_STATUS differently since moderated?
return (
0 /* \AMP_Validation_Error_Taxonomy::VALIDATION_ERROR_NEW_REJECTED_STATUS */ === result.status ||
1 /* \AMP_Validation_Error_Taxonomy::VALIDATION_ERROR_NEW_ACCEPTED_STATUS */ === result.status ||
2 /* \AMP_Validation_Error_Taxonomy::VALIDATION_ERROR_ACK_REJECTED_STATUS */ === result.status // eslint-disable-line no-magic-numbers
);
} ),
function( result ) {
return result.error;
}
);
// Short-circuit if there was no change to the validation errors.
if ( ! module.didValidationErrorsChange( validationErrors ) ) {
if ( ! validationErrors.length && ! module.lastStates.noticesAreReset ) {
module.lastStates.noticesAreReset = true;
module.resetWarningNotice();
}
return;
}
module.lastStates.validationErrors = validationErrors;
module.lastStates.noticesAreReset = false;
// Remove any existing notice.
module.resetWarningNotice();
noticeMessage = wp.i18n.sprintf(
/* translators: %s: number of issues */
wp.i18n._n(
'There is %s issue from AMP validation which needs review.',
'There are %s issues from AMP validation which need review.',
validationErrors.length,
'amp'
),
validationErrors.length
);
try {
blockValidationErrors = module.getBlocksValidationErrors();
module.lastStates.blockValidationErrors = blockValidationErrors.byClientId;
wp.data.dispatch( module.storeName ).updateBlocksValidationErrors( blockValidationErrors.byClientId );
blockErrorCount = validationErrors.length - blockValidationErrors.other.length;
if ( blockErrorCount > 0 ) {
noticeMessage += ' ' + wp.i18n.sprintf(
/* translators: %s: number of block errors. */
wp.i18n._n(
'%s issue is directly due to content here.',
'%s issues are directly due to content here.',
blockErrorCount,
'amp'
),
blockErrorCount
);
} else if ( validationErrors.length === 1 ) {
noticeMessage += ' ' + wp.i18n.__( 'The issue is not directly due to content here.', 'amp' );
} else {
noticeMessage += ' ' + wp.i18n.__( 'The issues are not directly due to content here.', 'amp' );
}
} catch ( e ) {
// Clear out block validation errors in case the block sand errors cannot be aligned.
module.resetBlockNotices();
if ( validationErrors.length === 1 ) {
noticeMessage += ' ' + wp.i18n.__( 'The issue may not be due to content here', 'amp' );
} else {
noticeMessage += ' ' + wp.i18n.__( 'Some issues may be due to content here.', 'amp' );
}
}
rejectedErrors = _.filter( ampValidity.results, function( result ) {
return (
0 /* \AMP_Validation_Error_Taxonomy::VALIDATION_ERROR_NEW_REJECTED_STATUS */ === result.status ||
2 /* \AMP_Validation_Error_Taxonomy::VALIDATION_ERROR_ACK_REJECTED_STATUS */ === result.status // eslint-disable-line no-magic-numbers
);
} );
noticeMessage += ' ';
// Auto-acceptance is from either checking 'Automatically accept sanitization...' or from being in Native mode.
if ( module.data.isSanitizationAutoAccepted ) {
if ( 0 === rejectedErrors.length ) {
noticeMessage += wp.i18n.__( 'However, your site is configured to automatically accept sanitization of the offending markup.', 'amp' );
} else {
noticeMessage += wp.i18n._n(
'Your site is configured to automatically accept sanitization errors, but this error could be from when auto-acceptance was not selected, or from manually rejecting an error.',
'Your site is configured to automatically accept sanitization errors, but these errors could be from when auto-acceptance was not selected, or from manually rejecting an error.',
validationErrors.length,
'amp'
);
}
} else {
noticeMessage += wp.i18n.__( 'Non-accepted validation errors prevent AMP from being served, and the user will be redirected to the non-AMP version.', 'amp' );
}
noticeOptions = {
id: 'amp-errors-notice'
};
if ( ampValidity.review_link ) {
noticeOptions.actions = [
{
label: wp.i18n.__( 'Review issues', 'amp' ),
url: ampValidity.review_link
}
];
}
// Display notice if there were validation errors.
if ( validationErrors.length > 0 ) {
wp.data.dispatch( 'core/notices' ).createNotice( 'warning', noticeMessage, noticeOptions );
}
module.validationWarningNoticeId = noticeOptions.id;
},
/**
* Checks if the validation errors have changed.
*
* @param {Object[]} validationErrors A list of validation errors.
* @return {boolean|*} Returns true when the validation errors change.
*/
didValidationErrorsChange: function didValidationErrorsChange( validationErrors ) {
if ( module.areBlocksOutOfSync() ) {
module.lastStates.validationErrors = [];
}
return (
module.lastStates.validationErrors.length !== validationErrors.length ||
( validationErrors && ! _.isEqual( module.lastStates.validationErrors, validationErrors ) )
);
},
/**
* Checks if the block order is out of sync.
*
* Block change on page load and can get out of sync during normal editing and saving processes. This method gives a check to determine if an "out of sync" condition occurred.
*
* @return {boolean} Whether out of sync.
*/
areBlocksOutOfSync: function areBlocksOutOfSync() {
var blockOrder = wp.data.select( 'core/editor' ).getBlockOrder();
if ( module.lastStates.blockOrder.length !== blockOrder.length || ! _.isEqual( module.lastStates.blockOrder, blockOrder ) ) {
module.lastStates.blockOrder = blockOrder;
return true;
}
return false;
},
/**
* Resets the validation warning notice.
*
* @return {void}
*/
resetWarningNotice: function resetWarningNotice() {
if ( module.validationWarningNoticeId ) {
wp.data.dispatch( 'core/notices' ).removeNotice( module.validationWarningNoticeId );
module.validationWarningNoticeId = null;
}
},
/**
* Resets the block level validation errors.
*
* @return {void}
*/
resetBlockNotices: function resetBlockNotices() {
wp.data.dispatch( module.storeName ).updateBlocksValidationErrors( {} );
},
/**
* Get flattened block order.
*
* @param {Object[]} blocks - List of blocks which maty have nested blocks inside them.
* @return {string[]} Block IDs in flattened order.
*/
getFlattenedBlockOrder: function getFlattenedBlockOrder( blocks ) {
var blockOrder = [];
_.each( blocks, function( block ) {
blockOrder.push( block.clientId );
if ( block.innerBlocks.length > 0 ) {
Array.prototype.push.apply( blockOrder, module.getFlattenedBlockOrder( block.innerBlocks ) );
}
} );
return blockOrder;
},
/**
* Update blocks' validation errors in the store.
*
* @return {Object} Validation errors grouped by block ID other ones.
*/
getBlocksValidationErrors: function getBlocksValidationErrors() {
var acceptedStatus, blockValidationErrorsByClientId, editorSelect, currentPost, blockOrder, validationErrors, otherValidationErrors;
acceptedStatus = 3; // eslint-disable-line no-magic-numbers
editorSelect = wp.data.select( 'core/editor' );
currentPost = editorSelect.getCurrentPost();
validationErrors = _.map(
_.filter( currentPost[ module.data.ampValidityRestField ].results, function( result ) {
return result.term_status !== acceptedStatus; // If not accepted by the user.
} ),
function( result ) {
return result.error;
}
);
blockOrder = module.getFlattenedBlockOrder( editorSelect.getBlocks() );
otherValidationErrors = [];
blockValidationErrorsByClientId = {};
_.each( blockOrder, function( clientId ) {
blockValidationErrorsByClientId[ clientId ] = [];
} );
_.each( validationErrors, function( validationError ) {
var i, source, clientId, block, matched;
if ( ! validationError.sources ) {
otherValidationErrors.push( validationError );
return;
}
// Find the inner-most nested block source only; ignore any nested blocks.
matched = false;
for ( i = validationError.sources.length - 1; 0 <= i; i-- ) {
source = validationError.sources[ i ];
// Skip sources that are not for blocks.
if ( ! source.block_name || _.isUndefined( source.block_content_index ) || currentPost.id !== source.post_id ) {
continue;
}
// Look up the block ID by index, assuming the blocks of content in the editor are the same as blocks rendered on frontend.
clientId = blockOrder[ source.block_content_index ];
if ( _.isUndefined( clientId ) ) {
throw new Error( 'undefined_block_index' );
}
// Sanity check that block exists for clientId.
block = editorSelect.getBlock( clientId );
if ( ! block ) {
throw new Error( 'block_lookup_failure' );
}
// Check the block type in case a block is dynamically added/removed via the_content filter to cause alignment error.
if ( block.name !== source.block_name ) {
throw new Error( 'ordered_block_alignment_mismatch' );
}
blockValidationErrorsByClientId[ clientId ].push( validationError );
matched = true;
// Stop looking for sources, since we aren't looking for parent blocks.
break;
}
if ( ! matched ) {
otherValidationErrors.push( validationError );
}
} );
return {
byClientId: blockValidationErrorsByClientId,
other: otherValidationErrors
};
},
/**
* Get message for validation error.
*
* @param {Object} validationError - Validation error.
* @param {string} validationError.code - Validation error code.
* @param {string} [validationError.node_name] - Node name.
* @param {string} [validationError.message] - Validation error message.
* @return {wp.element.Component[]|string[]} Validation error message.
*/
getValidationErrorMessage: function getValidationErrorMessage( validationError ) {
if ( validationError.message ) {
return validationError.message;
}
if ( 'invalid_element' === validationError.code && validationError.node_name ) {
return [
wp.i18n.__( 'Invalid element: ' ),
wp.element.createElement( 'code', { key: 'name' }, validationError.node_name )
];
} else if ( 'invalid_attribute' === validationError.code && validationError.node_name ) {
return [
wp.i18n.__( 'Invalid attribute: ' ),
wp.element.createElement( 'code', { key: 'name' }, validationError.parent_name ? wp.i18n.sprintf( '%s[%s]', validationError.parent_name, validationError.node_name ) : validationError.node_name )
];
}
return [
wp.i18n.__( 'Error code: ', 'amp' ),
wp.element.createElement( 'code', { key: 'name' }, validationError.code || wp.i18n.__( 'unknown' ) )
];
},
/**
* Wraps the edit() method of a block, and conditionally adds a Notice.
*
* @param {Function} BlockEdit - The original edit() method of the block.
* @return {Function} The edit() method, conditionally wrapped in a notice for AMP validation error(s).
*/
conditionallyAddNotice: function conditionallyAddNotice( BlockEdit ) {
return function( ownProps ) {
var validationErrors,
mergedProps;
function AmpNoticeBlockEdit( props ) {
var edit, details;
edit = wp.element.createElement(
BlockEdit,
props
);
if ( 0 === props.ampBlockValidationErrors.length ) {
return edit;
}
details = wp.element.createElement( 'details', { className: 'amp-block-validation-errors' }, [
wp.element.createElement( 'summary', { key: 'summary', className: 'amp-block-validation-errors__summary' }, wp.i18n.sprintf(
wp.i18n._n(
'There is %s issue from AMP validation.',
'There are %s issues from AMP validation.',
props.ampBlockValidationErrors.length,
'amp'
),
props.ampBlockValidationErrors.length
) ),
wp.element.createElement(
'ul',
{ key: 'list', className: 'amp-block-validation-errors__list' },
_.map( props.ampBlockValidationErrors, function( error, key ) {
return wp.element.createElement( 'li', { key: key }, module.getValidationErrorMessage( error ) );
} )
)
] );
return wp.element.createElement(
wp.element.Fragment, {},
wp.element.createElement(
wp.components.Notice,
{
status: 'warning',
isDismissible: false
},
details
),
edit
);
}
if ( ! module.lastStates.blockValidationErrors[ ownProps.clientId ] ) {
validationErrors = wp.data.select( module.storeName ).getBlockValidationErrors( ownProps.clientId );
module.lastStates.blockValidationErrors[ ownProps.clientId ] = validationErrors;
}
mergedProps = _.extend( {}, ownProps, {
ampBlockValidationErrors: module.lastStates.blockValidationErrors[ ownProps.clientId ]
} );
return AmpNoticeBlockEdit( mergedProps );
};
}
};
return module;
}() );