diff --git a/includes/classes/class-enqueue-frontend.php b/includes/classes/class-enqueue-frontend.php index abdd6c525..60931d338 100644 --- a/includes/classes/class-enqueue-frontend.php +++ b/includes/classes/class-enqueue-frontend.php @@ -97,16 +97,17 @@ public static function maybe_enqueue_frontend_highlighter() { 'edac-frontend-highlighter-app', 'edacFrontendHighlighterApp', [ - 'postID' => $post_id, - 'nonce' => wp_create_nonce( 'ajax-nonce' ), - 'restNonce' => wp_create_nonce( 'wp_rest' ), - 'userCanFix' => current_user_can( apply_filters( 'edac_filter_settings_capability', 'manage_options' ) ), - 'edacUrl' => esc_url_raw( get_site_url() ), - 'ajaxurl' => admin_url( 'admin-ajax.php' ), - 'loggedIn' => is_user_logged_in(), - 'appCssUrl' => EDAC_PLUGIN_URL . 'build/css/frontendHighlighterApp.css?ver=' . EDAC_VERSION, - 'widgetPosition' => get_option( 'edac_frontend_highlighter_position', 'right' ), - 'editorLink' => get_edit_post_link( $post_id ), + 'postID' => $post_id, + 'nonce' => wp_create_nonce( 'ajax-nonce' ), + 'restNonce' => wp_create_nonce( 'wp_rest' ), + 'userCanFix' => current_user_can( apply_filters( 'edac_filter_settings_capability', 'manage_options' ) ), + 'edacUrl' => esc_url_raw( get_site_url() ), + 'ajaxurl' => admin_url( 'admin-ajax.php' ), + 'loggedIn' => is_user_logged_in(), + 'appCssUrl' => EDAC_PLUGIN_URL . 'build/css/frontendHighlighterApp.css?ver=' . EDAC_VERSION, + 'widgetPosition' => get_option( 'edac_frontend_highlighter_position', 'right' ), + 'editorLink' => get_edit_post_link( $post_id ), + 'scannerBundleUrl' => plugin_dir_url( EDAC_PLUGIN_FILE ) . 'build/pageScanner.bundle.js', ] ); diff --git a/src/frontendHighlighterApp/index.js b/src/frontendHighlighterApp/index.js index dc3eb474a..2e28bf371 100644 --- a/src/frontendHighlighterApp/index.js +++ b/src/frontendHighlighterApp/index.js @@ -19,6 +19,7 @@ class AccessibilityCheckerHighlight { }; this.settings = { ...defaultSettings, ...settings }; + this._scanAttempted = false; this.highlightPanel = this.addHighlightPanel(); this.nextButton = document.querySelector( '#edac-highlight-next' ); @@ -185,15 +186,21 @@ class AccessibilityCheckerHighlight { }, ); } + } else if ( ! self._scanAttempted && response.data?.[ 0 ]?.code === -3 ) { + // Only try kickoffScan once per highlightAjax call + self._scanAttempted = true; + self.kickoffScan(); + // After kickoffScan, try highlightAjax again, but only once + setTimeout( () => { + self.highlightAjax().then( resolve ).catch( reject ); + }, 5000 ); // Wait 5s for scan to complete. } else { - resolve( [] ); - //console.log(response); + // Default: resolve with empty issues/fixes + resolve( { issues: [], fixes: [] } ); } } else { self.showWait( false ); - //console.log( 'Request failed. Returned status of ' + xhr.status ); - reject( { status: xhr.status, statusText: xhr.statusText, @@ -535,7 +542,11 @@ class AccessibilityCheckerHighlight { } } ).catch( ( err ) => { - //TODO: + // Output a message that says that there are no issues or that the issues could not be loaded. + const summary = document.querySelector( '.edac-highlight-panel-controls-summary' ); + if ( summary ) { + summary.textContent = __( 'An error occurred when loading the issues.', 'accessibility-checker' ); + } } ); } @@ -889,6 +900,115 @@ class AccessibilityCheckerHighlight { div.textContent = textContent; } + + /** + * Kick off the accessibility scan. + */ + kickoffScan() { + const getPageDensity = () => { + const elementCount = document.body.getElementsByTagName( '*' ).length; + const contentLength = document.body.innerText.length; + return { elementCount, contentLength }; + }; + const densityMetrics = getPageDensity(); + const self = this; + const scriptId = 'edac-accessibility-checker-scanner-script'; + if ( ! document.getElementById( scriptId ) ) { + const script = document.createElement( 'script' ); + script.src = window.edacFrontendHighlighterApp?.scannerBundleUrl || '/wp-content/plugins/accessibility-checker/build/pageScanner.bundle.js'; + script.id = scriptId; + script.onload = function() { + setTimeout( () => { + self._runScanOrShowError( densityMetrics ); + }, 100 ); + }; + script.onerror = function() { + self.showWait( false ); + self.showScanError( 'Failed to load scanner script.' ); + }; + document.head.appendChild( script ); + } else { + self._runScanOrShowError( densityMetrics ); + } + } + + _runScanOrShowError( densityMetrics ) { + if ( window.runAccessibilityScan ) { + this.runAccessibilityScanAndSave( densityMetrics ); + } else { + this.showWait( false ); + this.showScanError( __( 'Scanner function not found.', 'accessibility-checker' ) ); + } + } + + runAccessibilityScanAndSave( densityMetrics ) { + const self = this; + const summary = document.querySelector( '.edac-highlight-panel-controls-summary' ); + if ( summary ) { + summary.textContent = __( 'Scanning...', 'accessibility-checker' ); + summary.classList.remove( 'edac-error' ); + } + window.runAccessibilityScan().then( ( result ) => { + const postId = window.edacFrontendHighlighterApp && window.edacFrontendHighlighterApp.postID; + const nonce = window.edacFrontendHighlighterApp && window.edacFrontendHighlighterApp.restNonce; + if ( ! postId || ! nonce ) { + self.showWait( false ); + self.showScanError( __( 'Missing postId or nonce.', 'accessibility-checker' ) ); + return; + } + if ( ! result || ! result.violations || result.violations.length === 0 ) { + self.showWait( false ); + self.showScanError( __( 'No violations found, skipping save.', 'accessibility-checker' ) ); + return; + } + self.saveScanResults( postId, nonce, result.violations, densityMetrics ); + } ).catch( () => { + self.showWait( false ); + self.showScanError( __( 'Accessibility scan error.', 'accessibility-checker' ) ); + } ); + } + + saveScanResults( postId, nonce, violations, densityMetrics ) { + const self = this; + fetch( '/wp-json/accessibility-checker/v1/post-scan-results/' + postId, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': nonce, + }, + body: JSON.stringify( { + violations, + isSkipped: false, + isFailure: false, + densityMetrics, + } ), + } ) + .then( ( response ) => response.json() ) + .then( ( data ) => { + self.showWait( false ); + if ( data && data.success ) { + // Optionally show a success message or update UI + } else { + self.showScanError( __( 'Saving failed.', 'accessibility-checker' ) ); + } + } ) + .catch( () => { + self.showWait( false ); + self.showScanError( __( 'Error saving scan results.', 'accessibility-checker' ) ); + } ); + } + + /** + * Show an error message in the scan panel or as an alert fallback. + * @param {string} message + */ + showScanError( message ) { + const summary = document.querySelector( '.edac-highlight-panel-controls-summary' ); + if ( summary ) { + summary.textContent = message; + summary.classList.add( 'edac-error' ); + } + } } // Some systems (Cloudflare Rocket Loader) defers scripts for performance but that can diff --git a/src/pageScanner/index.js b/src/pageScanner/index.js index 5456a1c23..e9aefa2be 100644 --- a/src/pageScanner/index.js +++ b/src/pageScanner/index.js @@ -10,12 +10,39 @@ import { getPageDensity } from './helpers/density'; const SCAN_TIMEOUT_IN_SECONDS = 30; +// Hold the timeout for the scan so it can bail on long-running scans. +let tooLongTimeout; + // Read the data passed from the parent document. const body = document.querySelector( 'body' ); const iframeId = body.getAttribute( 'data-iframe-id' ); const eventName = body.getAttribute( 'data-iframe-event-name' ); const postId = body.getAttribute( 'data-iframe-post-id' ); +/** + * Check if the current context the script is loaded in is a scanner iframe. + * + * @return {boolean} True if in iframe context, false otherwise. + */ +function isIframeContext() { + return !! ( body && body.hasAttribute( 'data-iframe-id' ) && body.hasAttribute( 'data-iframe-event-name' ) ); +} + +/** + * Get the iframe options from the body attributes/ + * + * @return {Object} {{configOptions: {}, runOptions: {}, iframeId: string | Attribute, eventName: string | Attribute, postId: string | Attribute}} + */ +function getIframeOptions() { + return { + configOptions: {}, + runOptions: {}, + iframeId: body.getAttribute( 'data-iframe-id' ), + eventName: body.getAttribute( 'data-iframe-event-name' ), + postId: body.getAttribute( 'data-iframe-post-id' ), + }; +} + const scan = async ( options = { configOptions: {}, runOptions: {} } ) => { @@ -58,9 +85,10 @@ const scan = async ( //Build an array of the dom selectors and ruleIDs for violations/failed tests item.violations.forEach( ( violation ) => { if ( violation.result === 'failed' ) { + const el = document.querySelector( violation.node.selector ); violations.push( { selector: violation.node.selector, - html: document.querySelector( violation.node.selector ).outerHTML, + html: el ? el.outerHTML : null, ruleId: item.id, impact: item.impact, tags: item.tags, @@ -71,9 +99,10 @@ const scan = async ( // Handle incomplete results for form-field-multiple-labels only. if ( item.id === 'form-field-multiple-labels' ) { // Allow incomplete results for this rule. item.incomplete.forEach( ( incompleteItem ) => { + const el = document.querySelector( incompleteItem.node.selector ); violations.push( { selector: incompleteItem.node.selector, - html: document.querySelector( incompleteItem.node.selector ).outerHTML, + html: el ? el.outerHTML : null, ruleId: item.id, impact: item.impact, tags: item.tags, @@ -143,6 +172,7 @@ function dispatchDoneEvent( violations, errorMsgs, error ) { top.dispatchEvent( customEvent ); } +// eslint-disable-next-line no-unused-vars const onDone = ( violations = [], errorMsgs = [], error = false ) => { // cleanup the timeout. clearTimeout( tooLongTimeout ); @@ -153,38 +183,54 @@ const onDone = ( violations = [], errorMsgs = [], error = false ) => { function() { axe.teardown(); axe = null; - - dispatchDoneEvent( violations, errorMsgs, error ); + dispatchDoneEvent( violations, errorMsgs, '' ); }, function() { axe.teardown(); axe = null; - - // Create a custom event errorMsgs.push( '***** axe.cleanup() failed.' ); - - dispatchDoneEvent( violations, errorMsgs, error ); + dispatchDoneEvent( violations, errorMsgs, 'cleanup-failed' ); } ); } else { - error = true; - errorMsgs.push( '***** axe.cleanup() does not exist.' ); axe = null; - - dispatchDoneEvent( violations, errorMsgs, error ); + dispatchDoneEvent( violations, errorMsgs, 'cleanup-not-exists' ); } }; -// Fire a failed event if the scan doesn't complete on time. -const tooLongTimeout = setTimeout( function() { - onDone( [], [ '***** axe scan took too long.' ], true ); -}, SCAN_TIMEOUT_IN_SECONDS * 1000 ); - -// Start the scan. -scan().then( ( results ) => { - const violations = JSON.parse( JSON.stringify( results.violations ) ); - onDone( violations ); -} ).catch( ( err ) => { - onDone( [], [ err.message ], true ); -} ); +/** + * Attach an axe runner to the window object to allow for running the scan from + * the active document. + * + * @param {Object} options Options for the accessibility scan. + * @return {Promise} Promise resolving to the scan result. + */ +window.runAccessibilityScan = async function( options = {} ) { + return scan( options ) + .then( ( result ) => { + if ( typeof options.onComplete === 'function' ) { + options.onComplete( result ); + } + return result; + } ) + .catch( ( err ) => { + if ( typeof options.onComplete === 'function' ) { + options.onComplete( null, err ); + } + throw err; + } ); +}; + +// Auto-run scan and dispatch event to parent frame if in iframe context +if ( isIframeContext() ) { + const iframeOptions = getIframeOptions(); + + tooLongTimeout = setTimeout( () => { + dispatchDoneEvent( [], [ 'Scan timed out' ], 'timeout' ); + }, SCAN_TIMEOUT_IN_SECONDS * 1000 ); + + scan( iframeOptions ) + .then( ( result ) => onDone( result.violations, [], null ) ) + .catch( ( err ) => onDone( [], [ err.message || 'Unknown error' ], err.message ) ); +}