/** * 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; }() );