diff --git a/accessibility-checker.php b/accessibility-checker.php
index ae7839f98..cc7005a13 100755
--- a/accessibility-checker.php
+++ b/accessibility-checker.php
@@ -40,7 +40,7 @@
// Current database version.
if ( ! defined( 'EDAC_DB_VERSION' ) ) {
- define( 'EDAC_DB_VERSION', '1.0.3' );
+ define( 'EDAC_DB_VERSION', '1.0.4' );
}
// Plugin Folder Path.
diff --git a/admin/class-ajax.php b/admin/class-ajax.php
index 349e9c98c..69b66a609 100644
--- a/admin/class-ajax.php
+++ b/admin/class-ajax.php
@@ -305,7 +305,7 @@ function ( $a, $b ) {
foreach ( $rules as $rule ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Using direct query for interacting with custom database, safe variable used for table name, caching not required for one time operation.
- $results = $wpdb->get_results( $wpdb->prepare( 'SELECT id, postid, object, ruletype, ignre, ignre_user, ignre_date, ignre_comment, ignre_global FROM %i where postid = %d and rule = %s and siteid = %d', $table_name, $postid, $rule['slug'], $siteid ), ARRAY_A );
+ $results = $wpdb->get_results( $wpdb->prepare( 'SELECT id, postid, object, ruletype, ignre, ignre_user, ignre_date, ignre_comment, ignre_global, landmark, landmark_selector FROM %i where postid = %d and rule = %s and siteid = %d', $table_name, $postid, $rule['slug'], $siteid ), ARRAY_A );
$count_classes = ( 'error' === $rule['rule_type'] ) ? ' edac-details-rule-count-error' : ' edac-details-rule-count-warning';
$count_classes .= ( 0 !== $rule['count'] ) ? ' active' : '';
@@ -406,6 +406,8 @@ function ( $a, $b ) {
$html .= ob_get_clean();
}
+
+
$html .=
'
@@ -414,6 +416,9 @@ function ( $a, $b ) {
Image
+
+ Landmark
+
Actions
@@ -461,6 +466,30 @@ function ( $a, $b ) {
$html .= '
';
+ $html .= '
';
+
+ $landmark = isset( $row['landmark'] ) ? esc_html( $row['landmark'] ) : '';
+ $landmark_selector = isset( $row['landmark_selector'] ) ? $row['landmark_selector'] : '';
+
+ if ( $landmark && $landmark_selector ) {
+ $landmark_url = add_query_arg(
+ [
+ 'edac_landmark' => base64_encode( $landmark_selector ),
+ 'edac_nonce' => wp_create_nonce( 'edac_highlight' ),
+ ],
+ get_the_permalink( $postid )
+ );
+
+ // translators: %s is the landmark type (e.g., "Header", "Navigation", "Main").
+ $landmark_aria_label = sprintf( __( 'View %s landmark on website, opens a new window', 'accessibility-checker' ), ucwords( $landmark ) );
+ // translators: %s is the landmark type (e.g., "Header", "Navigation", "Main").
+ $html .= '
' . ucwords( $landmark ) . '';
+ } elseif ( $landmark ) {
+ $html .= ucwords( $landmark );
+ }
+
+ $html .= '
';
+
$html .= '
';
if ( ! isset( $rule['viewable'] ) || $rule['viewable'] ) {
diff --git a/admin/class-insert-rule-data.php b/admin/class-insert-rule-data.php
index db15a3816..51901827a 100644
--- a/admin/class-insert-rule-data.php
+++ b/admin/class-insert-rule-data.php
@@ -21,15 +21,17 @@ class Insert_Rule_Data {
*
* @since 1.10.0
*
- * @param object $post The post object. Must have a valid ID.
- * @param string $rule The rule.
- * @param string $ruletype The rule type.
- * @param string $rule_obj The object.
+ * @param object $post The post object. Must have a valid ID.
+ * @param string $rule The rule.
+ * @param string $ruletype The rule type.
+ * @param string $rule_obj The object.
+ * @param string|null $landmark The landmark type (main, header, footer, nav), optional.
+ * @param string|null $landmark_selector The landmark selector, optional.
*
* @return void|int|\WP_Error The ID of the inserted record, void if no
* record was inserted or a WP_Error if the insert failed.
*/
- public function insert( object $post, string $rule, string $ruletype, string $rule_obj ) {
+ public function insert( object $post, string $rule, string $ruletype, string $rule_obj, ?string $landmark = null, ?string $landmark_selector = null ) {
if ( ! isset( $post->ID, $post->post_type )
|| empty( $rule )
@@ -44,19 +46,21 @@ public function insert( object $post, string $rule, string $ruletype, string $ru
// set up rule data array.
$rule_data = [
- 'postid' => $post->ID,
- 'siteid' => get_current_blog_id(),
- 'type' => $post->post_type,
- 'rule' => $rule,
- 'ruletype' => $ruletype,
- 'object' => esc_attr( $rule_obj ),
- 'recordcheck' => 1,
- 'user' => get_current_user_id(),
- 'ignre' => 0,
- 'ignre_user' => null,
- 'ignre_date' => null,
- 'ignre_comment' => null,
- 'ignre_global' => 0,
+ 'postid' => $post->ID,
+ 'siteid' => get_current_blog_id(),
+ 'type' => $post->post_type,
+ 'landmark' => $landmark,
+ 'landmark_selector' => $landmark_selector,
+ 'rule' => $rule,
+ 'ruletype' => $ruletype,
+ 'object' => esc_attr( $rule_obj ),
+ 'recordcheck' => 1,
+ 'user' => get_current_user_id(),
+ 'ignre' => 0,
+ 'ignre_user' => null,
+ 'ignre_date' => null,
+ 'ignre_comment' => null,
+ 'ignre_global' => 0,
];
// return if revision.
@@ -125,19 +129,21 @@ public function insert( object $post, string $rule, string $ruletype, string $ru
// the data is still as valid as it was when it was first set.
// Sanitize the filtered data.
$rule_data_sanitized = [
- 'postid' => absint( $rule_data['postid'] ),
- 'siteid' => absint( $rule_data['siteid'] ),
- 'type' => sanitize_text_field( $rule_data['type'] ),
- 'rule' => sanitize_text_field( $rule_data['rule'] ),
- 'ruletype' => sanitize_text_field( $rule_data['ruletype'] ),
- 'object' => esc_attr( $rule_data['object'] ),
- 'recordcheck' => absint( $rule_data['recordcheck'] ),
- 'user' => absint( $rule_data['user'] ),
- 'ignre' => absint( $rule_data['ignre'] ),
- 'ignre_user' => isset( $rule_data['ignre_user'] ) ? absint( $rule_data['ignre_user'] ) : null,
- 'ignre_date' => isset( $rule_data['ignre_date'] ) ? sanitize_text_field( $rule_data['ignre_date'] ) : null,
- 'ignre_comment' => isset( $rule_data['ignre_comment'] ) ? sanitize_text_field( $rule_data['ignre_comment'] ) : null,
- 'ignre_global' => absint( $rule_data['ignre_global'] ),
+ 'postid' => absint( $rule_data['postid'] ),
+ 'siteid' => absint( $rule_data['siteid'] ),
+ 'type' => sanitize_text_field( $rule_data['type'] ),
+ 'landmark' => isset( $rule_data['landmark'] ) ? sanitize_text_field( $rule_data['landmark'] ) : null,
+ 'landmark_selector' => isset( $rule_data['landmark_selector'] ) ? sanitize_text_field( $rule_data['landmark_selector'] ) : null,
+ 'rule' => sanitize_text_field( $rule_data['rule'] ),
+ 'ruletype' => sanitize_text_field( $rule_data['ruletype'] ),
+ 'object' => esc_attr( $rule_data['object'] ),
+ 'recordcheck' => absint( $rule_data['recordcheck'] ),
+ 'user' => absint( $rule_data['user'] ),
+ 'ignre' => absint( $rule_data['ignre'] ),
+ 'ignre_user' => isset( $rule_data['ignre_user'] ) ? absint( $rule_data['ignre_user'] ) : null,
+ 'ignre_date' => isset( $rule_data['ignre_date'] ) ? sanitize_text_field( $rule_data['ignre_date'] ) : null,
+ 'ignre_comment' => isset( $rule_data['ignre_comment'] ) ? sanitize_text_field( $rule_data['ignre_comment'] ) : null,
+ 'ignre_global' => absint( $rule_data['ignre_global'] ),
];
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Using direct query for adding data to database.
diff --git a/admin/class-update-database.php b/admin/class-update-database.php
index 894882373..10bd6f30d 100644
--- a/admin/class-update-database.php
+++ b/admin/class-update-database.php
@@ -48,6 +48,8 @@ public function edac_update_database() {
postid bigint(20) NOT NULL,
siteid text NOT NULL,
type text NOT NULL,
+ landmark varchar(20) NULL,
+ landmark_selector text NULL,
rule text NOT NULL,
ruletype text NOT NULL,
object mediumtext NOT NULL,
diff --git a/includes/classes/class-rest-api.php b/includes/classes/class-rest-api.php
index fb4fe4fbe..ea6fa2ce0 100644
--- a/includes/classes/class-rest-api.php
+++ b/includes/classes/class-rest-api.php
@@ -380,7 +380,10 @@ public function set_post_scan_results( $request ) {
*/
do_action( 'edac_before_rule', $post_id, $actual_rule_id, 'js' );
- ( new Insert_Rule_Data() )->insert( $post, $actual_rule_id, $impact, $html );
+ $landmark = $violation['landmark'] ?? null;
+ $landmark_selector = $violation['landmarkSelector'] ?? null;
+
+ ( new Insert_Rule_Data() )->insert( $post, $actual_rule_id, $impact, $html, $landmark, $landmark_selector );
/**
* Fires after a rule is run against the content.
diff --git a/src/admin/sass/accessibility-checker-admin.scss b/src/admin/sass/accessibility-checker-admin.scss
index d8e57ae83..8246e2de4 100644
--- a/src/admin/sass/accessibility-checker-admin.scss
+++ b/src/admin/sass/accessibility-checker-admin.scss
@@ -621,7 +621,7 @@
color: variables.$color-white;
background-color: variables.$color-dark-gray;
display: grid;
- grid-template-columns: auto 125px 125px;
+ grid-template-columns: auto 125px 125px 125px;
&-label {
padding: 3px;
@@ -631,7 +631,7 @@
&-record {
display: grid;
- grid-template-columns: auto 125px 125px;
+ grid-template-columns: auto 125px 125px 125px;
&-cell {
padding: 10px;
diff --git a/src/frontendHighlighterApp/index.js b/src/frontendHighlighterApp/index.js
index dc3eb474a..50cabdc07 100644
--- a/src/frontendHighlighterApp/index.js
+++ b/src/frontendHighlighterApp/index.js
@@ -32,6 +32,7 @@ class AccessibilityCheckerHighlight {
this.fixes = null;
this.currentButtonIndex = null;
this.urlParameter = this.get_url_parameter( 'edac' );
+ this.landmarkParameter = this.get_url_parameter( 'edac_landmark' );
this.currentIssueStatus = null;
this.tooltips = [];
this.panelControlsFocusTrap = createFocusTrap( '#' + this.panelControls.id, {
@@ -97,6 +98,8 @@ class AccessibilityCheckerHighlight {
// Open panel if a URL parameter exists
if ( this.urlParameter ) {
this.panelOpen( this.urlParameter );
+ } else if ( this.landmarkParameter ) {
+ this.highlightLandmark( this.landmarkParameter );
}
}
@@ -242,6 +245,9 @@ class AccessibilityCheckerHighlight {
buttons.forEach( ( button ) => {
button.remove();
} );
+
+ // Clean up any landmark labels
+ this.removeLandmarkLabels();
}
/**
@@ -577,6 +583,9 @@ class AccessibilityCheckerHighlight {
selectedElement.removeAttribute( 'class' );
}
} );
+
+ // Clean up any landmark labels when highlights are removed
+ this.removeLandmarkLabels();
};
/**
@@ -889,6 +898,196 @@ class AccessibilityCheckerHighlight {
div.textContent = textContent;
}
+
+ /**
+ * This function highlights a landmark based on the selector.
+ * @param {string} encodedSelector Base64-encoded CSS selector for the landmark
+ */
+ highlightLandmark( encodedSelector ) {
+ try {
+ // Decode the base64 selector
+ const selector = atob( encodedSelector );
+
+ // Find the landmark element using multiple strategies
+ let landmarkElement = null;
+
+ try {
+ // Try the original selector first
+ landmarkElement = document.querySelector( selector );
+ } catch ( error ) {
+ // Selector might be invalid, try fallbacks
+ }
+
+ // If original selector failed, try some fallback strategies
+ if ( ! landmarkElement ) {
+ // Try common landmark selectors as fallbacks
+ const fallbackSelectors = [
+ // Remove complex pseudo-selectors and try simpler versions
+ selector.replace( /:nth-child\(\d+\)/g, '' ).replace( /\s+>\s+/g, ' ' ),
+ // Try just the last part of the selector
+ selector.split( ' > ' ).pop(),
+ // Try without classes
+ selector.replace( /\.[^:\s>]+/g, '' ),
+ ];
+
+ for ( const fallback of fallbackSelectors ) {
+ if ( fallback && fallback.trim() ) {
+ try {
+ landmarkElement = document.querySelector( fallback.trim() );
+ if ( landmarkElement ) {
+ break;
+ }
+ } catch ( e ) {
+ // Continue to next fallback
+ }
+ }
+ }
+ }
+
+ if ( landmarkElement ) {
+ // Clean up any existing landmark labels first
+ this.removeLandmarkLabels();
+
+ // Add highlighting styles
+ landmarkElement.classList.add( 'edac-highlight-element-selected' );
+ landmarkElement.classList.add( 'edac-landmark-highlight' );
+
+ // Create and add landmark type label
+ const landmarkType = this.getLandmarkType( landmarkElement );
+ const landmarkLabel = document.createElement( 'div' );
+ landmarkLabel.classList.add( 'edac-landmark-label' );
+ landmarkLabel.textContent = `Landmark: ${ landmarkType }`;
+ landmarkLabel.setAttribute( 'aria-hidden', 'true' );
+ landmarkLabel.style.cssText = `
+ position: absolute;
+ background: #072446;
+ color: white;
+ padding: 4px 8px;
+ font-size: 12px;
+ font-weight: bold;
+ border-radius: 3px;
+ z-index: 99998;
+ pointer-events: none;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ line-height: 1;
+ box-shadow: 0 2px 4px rgba(0,0,0,0.2);
+ `;
+
+ // Position the label inside the top-left corner of the landmark
+ const rect = landmarkElement.getBoundingClientRect();
+ landmarkLabel.style.left = ( rect.left + window.scrollX - 0 ) + 'px'; // 15px inside from left edge
+ landmarkLabel.style.top = ( rect.top + window.scrollY - 0 ) + 'px'; // 15px inside from top edge
+
+ // Add label to the page
+ document.body.appendChild( landmarkLabel );
+
+ // Store reference for cleanup
+ landmarkElement.setAttribute( 'data-edac-landmark-label-id', Date.now() );
+ landmarkLabel.setAttribute( 'data-edac-landmark-for', landmarkElement.getAttribute( 'data-edac-landmark-label-id' ) );
+
+ // Adjust for small elements
+ if ( landmarkElement.offsetWidth < 20 ) {
+ landmarkElement.classList.add( 'edac-highlight-element-selected-min-width' );
+ }
+
+ if ( landmarkElement.offsetHeight < 5 ) {
+ landmarkElement.classList.add( 'edac-highlight-element-selected-min-height' );
+ }
+
+ // Scroll to the landmark with 20px offset from start
+ const elementRect = landmarkElement.getBoundingClientRect();
+ const elementTop = elementRect.top + window.scrollY - 75;
+ window.scrollTo( {
+ top: elementTop,
+ behavior: 'smooth',
+ } );
+
+ } else {
+ // Landmark element not found - silently fail
+ }
+ } catch ( error ) {
+ // Error highlighting landmark - silently fail
+ }
+ }
+
+ /**
+ * Determines the landmark type of an element
+ * @param {HTMLElement} element The element to check
+ * @return {string} The landmark type (e.g., "Header", "Navigation", "Main")
+ */
+ getLandmarkType( element ) {
+ // Check explicit ARIA role first
+ const role = element.getAttribute( 'role' );
+ if ( role ) {
+ switch ( role.toLowerCase() ) {
+ case 'banner':
+ return 'Header';
+ case 'navigation':
+ return 'Navigation';
+ case 'main':
+ return 'Main';
+ case 'complementary':
+ return 'Complementary';
+ case 'contentinfo':
+ return 'Footer';
+ case 'search':
+ return 'Search';
+ case 'form':
+ return 'Form';
+ case 'region':
+ return 'Region';
+ default:
+ return role.charAt( 0 ).toUpperCase() + role.slice( 1 );
+ }
+ }
+
+ // Check semantic HTML elements
+ const tagName = element.tagName.toLowerCase();
+ switch ( tagName ) {
+ case 'header':
+ return 'Header';
+ case 'nav':
+ return 'Navigation';
+ case 'main':
+ return 'Main';
+ case 'aside':
+ return 'Complementary';
+ case 'footer':
+ return 'Footer';
+ case 'section': {
+ // Check if section has accessible name
+ const hasAccessibleName = element.getAttribute( 'aria-label' ) ||
+ element.getAttribute( 'aria-labelledby' ) ||
+ element.querySelector( 'h1, h2, h3, h4, h5, h6' );
+ return hasAccessibleName ? 'Region' : 'Section';
+ }
+ case 'form': {
+ // Check if form has accessible name
+ const formHasAccessibleName = element.getAttribute( 'aria-label' ) ||
+ element.getAttribute( 'aria-labelledby' );
+ return formHasAccessibleName ? 'Form' : 'Form (unlabeled)';
+ }
+ default:
+ return 'Landmark';
+ }
+ }
+
+ /**
+ * Remove all landmark labels from the page
+ */
+ removeLandmarkLabels() {
+ const landmarkLabels = document.querySelectorAll( '.edac-landmark-label' );
+ landmarkLabels.forEach( ( label ) => {
+ label.remove();
+ } );
+
+ // Remove landmark highlight classes
+ const landmarkHighlights = document.querySelectorAll( '.edac-landmark-highlight' );
+ landmarkHighlights.forEach( ( element ) => {
+ element.classList.remove( 'edac-landmark-highlight' );
+ element.removeAttribute( 'data-edac-landmark-label-id' );
+ } );
+ }
}
// Some systems (Cloudflare Rocket Loader) defers scripts for performance but that can
diff --git a/src/frontendHighlighterApp/sass/app.scss b/src/frontendHighlighterApp/sass/app.scss
index 4c91a7ac2..b840a9e7e 100644
--- a/src/frontendHighlighterApp/sass/app.scss
+++ b/src/frontendHighlighterApp/sass/app.scss
@@ -34,6 +34,8 @@ body {
outline: dashed 4px transparent !important;
outline-offset: 5px !important;
outline-color: magenta !important;
+ max-width: calc(100vw - 30px) !important;
+ box-sizing: border-box !important;
&-min-width {
min-width: 25px !important;
diff --git a/src/pageScanner/index.js b/src/pageScanner/index.js
index 5456a1c23..5d306305b 100644
--- a/src/pageScanner/index.js
+++ b/src/pageScanner/index.js
@@ -10,6 +10,158 @@ import { getPageDensity } from './helpers/density';
const SCAN_TIMEOUT_IN_SECONDS = 30;
+// Landmark tags for semantic regions
+const LANDMARK_TAGS = [ 'MAIN', 'HEADER', 'FOOTER', 'NAV', 'ASIDE' ];
+const LANDMARK_ROLES = [
+ 'main',
+ 'navigation',
+ 'banner',
+ 'contentinfo',
+ 'complementary',
+];
+
+// Conditional landmark tags that only become landmarks when they have accessible names
+const CONDITIONAL_LANDMARK_TAGS = [ 'SECTION', 'ARTICLE', 'FORM' ];
+const CONDITIONAL_LANDMARK_ROLES = [ 'region', 'article', 'form' ];
+
+function getLandmarkForSelector( selector ) {
+ const el = document.querySelector( selector );
+ if ( ! el ) {
+ return { type: null, selector: null };
+ }
+ let current = el;
+ while ( current && current !== document.body ) {
+ // Check unconditional landmark tags
+ if ( LANDMARK_TAGS.includes( current.tagName ) ) {
+ return { type: current.tagName.toLowerCase(), selector: getElementSelector( current ) };
+ }
+
+ // Check conditional landmark tags (require accessible name)
+ if (
+ CONDITIONAL_LANDMARK_TAGS.includes( current.tagName ) &&
+ ( current.hasAttribute( 'aria-label' ) || current.hasAttribute( 'aria-labelledby' ) )
+ ) {
+ return { type: current.tagName.toLowerCase(), selector: getElementSelector( current ) };
+ }
+
+ // Check roles
+ if ( current.hasAttribute( 'role' ) ) {
+ const role = current.getAttribute( 'role' ).toLowerCase();
+
+ // Check unconditional landmark roles
+ if ( LANDMARK_ROLES.includes( role ) ) {
+ return { type: role, selector: getElementSelector( current ) };
+ }
+
+ // Check conditional landmark roles (require accessible name)
+ if (
+ CONDITIONAL_LANDMARK_ROLES.includes( role ) &&
+ ( current.hasAttribute( 'aria-label' ) || current.hasAttribute( 'aria-labelledby' ) )
+ ) {
+ return { type: role, selector: getElementSelector( current ) };
+ }
+ }
+
+ current = current.parentElement;
+ }
+ return { type: null, selector: null };
+}
+
+// Helper to get a unique CSS selector for an element
+function getElementSelector( element ) {
+ if ( ! element ) {
+ return null;
+ }
+
+ // Use ID if available (most reliable)
+ if ( element.id ) {
+ return `#${ element.id }`;
+ }
+
+ // For landmark elements, try to use semantic selectors first
+ const tagName = element.tagName.toLowerCase();
+
+ // For main element, use tag selector if it's unique
+ if ( tagName === 'main' ) {
+ const mainElements = document.querySelectorAll( 'main' );
+ if ( mainElements.length === 1 ) {
+ return 'main';
+ }
+ }
+
+ // For header/footer, check if they're direct children of body
+ if ( ( tagName === 'header' || tagName === 'footer' ) && element.parentElement === document.body ) {
+ return tagName;
+ }
+
+ // For nav elements, try role-based selector first
+ if ( tagName === 'nav' || element.getAttribute( 'role' ) === 'navigation' ) {
+ const navElements = document.querySelectorAll( 'nav, [role="navigation"]' );
+ if ( navElements.length === 1 ) {
+ return tagName === 'nav' ? 'nav' : '[role="navigation"]';
+ }
+ // If multiple, try to use aria-label or other identifying attributes
+ if ( element.hasAttribute( 'aria-label' ) ) {
+ const ariaLabel = element.getAttribute( 'aria-label' );
+ return `${ tagName === 'nav' ? 'nav' : '[role="navigation"]' }[aria-label="${ ariaLabel }"]`;
+ }
+ }
+
+ // For other landmark roles, use role selector if unique
+ const role = element.getAttribute( 'role' );
+ if ( role && LANDMARK_ROLES.includes( role ) ) {
+ const roleElements = document.querySelectorAll( `[role="${ role }"]` );
+ if ( roleElements.length === 1 ) {
+ return `[role="${ role }"]`;
+ }
+ // If multiple, try to use aria-label
+ if ( element.hasAttribute( 'aria-label' ) ) {
+ const ariaLabel = element.getAttribute( 'aria-label' );
+ return `[role="${ role }"][aria-label="${ ariaLabel }"]`;
+ }
+ }
+
+ // Fallback to path-based selector (simplified)
+ const path = [];
+ let current = element;
+ while ( current && current.nodeType === Node.ELEMENT_NODE && current !== document.body ) {
+ let selector = current.nodeName.toLowerCase();
+
+ // Add ID if available
+ if ( current.id ) {
+ selector = `#${ current.id }`;
+ path.unshift( selector );
+ break; // Stop here since ID is unique
+ }
+
+ // Add stable classes (avoid dynamic/generated classes)
+ if ( current.className ) {
+ const classes = current.className.trim().split( /\s+/ )
+ .map( ( cls ) => CSS.escape( cls ) )
+ .filter( ( cls ) => ! cls.match( /^(wp-|js-|css-|generated-|dynamic-)/ ) ) // Filter out common dynamic classes
+ .slice( 0, 2 ); // Limit to first 2 classes for stability
+ if ( classes.length > 0 ) {
+ selector += `.${ classes.join( '.' ) }`;
+ }
+ }
+
+ // Only add nth-child as last resort and only if element has no other identifying features
+ if ( ! current.id && ! current.className ) {
+ const siblingIndex = Array.from( current.parentNode.children ).indexOf( current ) + 1;
+ selector += `:nth-child(${ siblingIndex })`;
+ }
+
+ path.unshift( selector );
+ current = current.parentElement;
+
+ // Limit path depth to avoid overly complex selectors
+ if ( path.length >= 4 ) {
+ break;
+ }
+ }
+ return path.length ? path.join( ' > ' ) : null;
+}
+
// Read the data passed from the parent document.
const body = document.querySelector( 'body' );
const iframeId = body.getAttribute( 'data-iframe-id' );
@@ -58,12 +210,17 @@ 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 selector = violation.node.selector;
+ const html = document.querySelector( selector )?.outerHTML;
+ const landmark = getLandmarkForSelector( selector );
violations.push( {
- selector: violation.node.selector,
- html: document.querySelector( violation.node.selector ).outerHTML,
+ selector,
+ html,
ruleId: item.id,
impact: item.impact,
tags: item.tags,
+ landmark: landmark.type,
+ landmarkSelector: landmark.selector,
} );
}
} );
@@ -71,12 +228,17 @@ 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 selector = incompleteItem.node.selector;
+ const html = document.querySelector( selector )?.outerHTML;
+ const landmark = getLandmarkForSelector( selector );
violations.push( {
- selector: incompleteItem.node.selector,
- html: document.querySelector( incompleteItem.node.selector ).outerHTML,
+ selector,
+ html,
ruleId: item.id,
impact: item.impact,
tags: item.tags,
+ landmark: landmark.type,
+ landmarkSelector: landmark.selector,
} );
} );
}
diff --git a/tests/phpunit/Admin/InsertRuleDataTest.php b/tests/phpunit/Admin/InsertRuleDataTest.php
index a2cef58c3..3e3d7ead2 100644
--- a/tests/phpunit/Admin/InsertRuleDataTest.php
+++ b/tests/phpunit/Admin/InsertRuleDataTest.php
@@ -37,6 +37,8 @@ public function setUp(): void {
ignre_user bigint(20) NULL,
ignre_date timestamp NULL,
ignre_comment mediumtext NULL,
+ landmark varchar(20) NULL,
+ landmark_selector text NULL,
UNIQUE KEY id (id),
KEY postid_index (postid)
) $charset_collate;";