diff --git a/accessibility-checker.php b/accessibility-checker.php
index d7b477c3a..d6b406194 100755
--- a/accessibility-checker.php
+++ b/accessibility-checker.php
@@ -10,7 +10,7 @@
* Plugin Name: Accessibility Checker
* Plugin URI: https://equalizedigital.com/accessibility-checker
* Description: Audit and check your website for accessibility before you hit publish. In-post accessibility scanner and guidance.
- * Version: 1.46.0
+ * Version: 1.47.0
* Requires PHP: 7.4
* Author: Equalize Digital
* Author URI: https://equalizedigital.com
@@ -36,7 +36,7 @@
// Current plugin version.
if ( ! defined( 'EDAC_VERSION' ) ) {
- define( 'EDAC_VERSION', '1.46.0' );
+ define( 'EDAC_VERSION', '1.47.0' );
}
// Current database version.
diff --git a/admin/class-ajax.php b/admin/class-ajax.php
index 3f34ddbe0..2bb38a891 100644
--- a/admin/class-ajax.php
+++ b/admin/class-ajax.php
@@ -526,7 +526,15 @@ function ( $a, $b ) {
)
) . '" />';
} elseif ( $object_svg ) {
- $html .= $object_svg;
+ // Rendered as an
via a data URI, not injected as inline markup -
+ // see edac_svg_markup_to_data_uri()'s docblock for why.
+ $html .= '
';
}
$html .= '';
diff --git a/admin/class-enqueue-admin.php b/admin/class-enqueue-admin.php
index 46eb51590..fcc888026 100644
--- a/admin/class-enqueue-admin.php
+++ b/admin/class-enqueue-admin.php
@@ -78,6 +78,11 @@ public static function maybe_enqueue_admin_and_editor_app_scripts() {
global $post;
$post_id = is_object( $post ) ? $post->ID : null;
+
+ // On a latest-posts homepage the global $post is the first blog post, not the page;
+ // let extensions supply the correct ID (e.g. a Pro virtual-page ID).
+ $post_id = apply_filters( 'edac_filter_admin_post_id', $post_id );
+
wp_enqueue_script( 'edac', plugin_dir_url( EDAC_PLUGIN_FILE ) . 'build/admin.bundle.js', [ 'jquery' ], EDAC_VERSION, false );
wp_set_script_translations( 'edac', 'accessibility-checker', plugin_dir_path( EDAC_PLUGIN_FILE ) . 'languages' );
@@ -99,8 +104,9 @@ public static function maybe_enqueue_admin_and_editor_app_scripts() {
if ( 'post.php' === $pagenow || 'post-new.php' === $pagenow ) {
- // Is this posttype setup to be checked?
- $active = $is_scannable_post;
+ // Base the scannable check on the filtered $post_id, not the original global $post.
+ $filtered_post_type = $post_id ? get_post_type( $post_id ) : false;
+ $active = $filtered_post_type && is_array( $post_types ) && in_array( $filtered_post_type, $post_types, true );
$pro = defined( 'EDACP_VERSION' ) && EDAC_KEY_VALID;
@@ -113,8 +119,15 @@ public static function maybe_enqueue_admin_and_editor_app_scripts() {
wp_enqueue_script( 'edac-editor-app', plugin_dir_url( EDAC_PLUGIN_FILE ) . 'build/editorApp.bundle.js', false, EDAC_VERSION, false );
wp_set_script_translations( 'edac-editor-app', 'accessibility-checker', plugin_dir_path( EDAC_PLUGIN_FILE ) . 'languages' );
- // If this is the frontpage or homepage, preview URLs won't work. Use the live URL.
- if ( (int) get_option( 'page_on_front' ) === $post_id || (int) get_option( 'page_for_posts' ) === $post_id ) {
+ // Preview URLs don't work for the homepage. On a latest-posts homepage (including the
+ // show_on_front=page fallback with no static front page) use the live home URL instead.
+ $show_on_front = get_option( 'show_on_front', 'posts' );
+ $is_latest_posts_home = ( 'posts' === $show_on_front || ( 'page' === $show_on_front && ! get_option( 'page_on_front' ) ) )
+ && apply_filters( 'edac_filter_post_is_latest_posts_home', false, $post_id );
+
+ if ( $is_latest_posts_home ) {
+ $scan_url = add_query_arg( 'edac_pageScanner', 1, trailingslashit( get_home_url() ) );
+ } elseif ( (int) get_option( 'page_on_front' ) === $post_id || (int) get_option( 'page_for_posts' ) === $post_id ) {
$scan_url = add_query_arg( 'edac_pageScanner', 1, get_permalink( $post_id ) );
} else {
$post_view_link = apply_filters(
diff --git a/admin/class-helpers.php b/admin/class-helpers.php
index 70b67db3e..3d700fe4d 100644
--- a/admin/class-helpers.php
+++ b/admin/class-helpers.php
@@ -139,54 +139,6 @@ public static function get_option_as_array( $option_name ) {
return [];
}
-
- /**
- * Determine if a domain is hosted on a local loopback
- *
- * @param string $domain The domain to check.
- * @return boolean
- */
- public static function is_domain_loopback( $domain ) {
-
- // Check if this is an ipv4 address in the loopback range.
-
- $record = gethostbyname( $domain );
- $loopback_start = ip2long( '127.0.0.0' );
- $loopback_end = ip2long( '127.255.255.255' );
- $ip_long = ip2long( $record );
-
- if ( $ip_long >= $loopback_start && $ip_long <= $loopback_end ) {
- return true;
- }
-
- // Check if this is an ipv6 loopback.
-
- try {
- $records = dns_get_record( $domain, DNS_AAAA );
- } catch ( \Throwable $th ) {
- return false;
- }
-
- foreach ( $records as $record ) {
-
- // Do ipv6 check.
- if ( isset( $record['type'] ) && 'AAAA' === $record['type'] ) {
-
- // Normalize the IPv6 address for comparison.
- $normalized_ipv6 = inet_pton( $record['ipv6'] );
-
- // Normalize the loopback address.
- $loopback_ipv6 = inet_pton( '::1' );
-
- if ( $normalized_ipv6 === $loopback_ipv6 ) {
- return true;
- }
- }
- }
-
- return false;
- }
-
/**
* Filter out inactive rules from the results returned.
*
diff --git a/changelog.txt b/changelog.txt
index 93716661c..09348ae3b 100644
--- a/changelog.txt
+++ b/changelog.txt
@@ -1,5 +1,15 @@
*** Accessibility Checker ***
+2026-07-14 - version 1.47.0
+* Updated - the frontend highlighter now draws a white ring around the outline so highlighted elements stay visible on any background color.
+* Updated - the empty alt text check no longer flags 1x1 tracking pixels.
+* Updated - the link protocol checks now handle href values that contain leading whitespace.
+* Updated - SVG code snippets in the Accessibility Analysis panel are now rendered as images instead of raw markup.
+* Fix - scan results for the homepage are now stored against the homepage instead of the first blog post when the site is set to show latest posts.
+* Fix - the Simplified Summary text areas in the editor sidebar now have a label that assistive technology can announce.
+* Fix - corrected a caching issue that could cause database table name lookups to return the wrong table.
+* Remove - deleted an unused internal helper function.
+
2026-07-08 - version 1.46.0
* Updated - the aria-hidden rule no longer flags core Cover blocks.
* Updated - the Incorrect Heading Order summary no longer claims specific heading levels that may not match the actual issue.
diff --git a/includes/classes/class-enqueue-frontend.php b/includes/classes/class-enqueue-frontend.php
index ace4787f3..a2f39efda 100644
--- a/includes/classes/class-enqueue-frontend.php
+++ b/includes/classes/class-enqueue-frontend.php
@@ -102,7 +102,14 @@ public static function maybe_enqueue_frontend_highlighter() {
// Don't load on the frontend if we don't have a post to work with.
global $post;
- $post_id = apply_filters( 'edac_filter_frontend_highlight_post_id', is_object( $post ) ? $post->ID : null );
+
+ // On a latest-posts homepage the global $post is the first blog post, so using its ID
+ // would misattribute results; pass null and let the filter supply an ID (Pro) or bail.
+ $default_post_id = ( is_home() && is_front_page() )
+ ? null
+ : ( is_object( $post ) ? $post->ID : null );
+
+ $post_id = apply_filters( 'edac_filter_frontend_highlight_post_id', $default_post_id );
if ( null === $post_id ) {
return;
diff --git a/includes/helper-functions.php b/includes/helper-functions.php
index 7401ed93b..cb51b374b 100644
--- a/includes/helper-functions.php
+++ b/includes/helper-functions.php
@@ -248,10 +248,10 @@ function edac_get_post_type_label( string $post_type ): string {
*/
function edac_get_valid_table_name( $table_name ) {
global $wpdb;
- static $found_table_name;
+ static $found_table_names = [];
- if ( isset( $found_table_name ) ) {
- return $found_table_name;
+ if ( isset( $found_table_names[ $table_name ] ) ) {
+ return $found_table_names[ $table_name ];
}
// Check if table name only contains alphanumeric characters, underscores, or hyphens.
@@ -267,8 +267,8 @@ function edac_get_valid_table_name( $table_name ) {
return null;
}
- $found_table_name = $table_name;
- return $found_table_name;
+ $found_table_names[ $table_name ] = $table_name;
+ return $table_name;
}
/**
@@ -788,6 +788,27 @@ function edac_parse_html_for_media( $html ) {
];
}
+/**
+ * Convert raw SVG markup into a data: URI, safe as an
src - browsers
+ * don't execute scripts or event handlers in SVGs loaded as images. Returns
+ * a bare (payload-less) data URI if given anything other than a string.
+ *
+ * @since 1.47.0
+ *
+ * @param mixed $svg_markup Raw SVG markup - expected to be a string.
+ * @return string Unescaped data URI - callers must esc_url() it before output,
+ * passing a protocols list that includes 'data' (e.g.
+ * esc_url( $uri, [ 'data', 'http', 'https' ] )); with the
+ * default protocols esc_url() rejects data: URIs and returns ''.
+ */
+function edac_svg_markup_to_data_uri( $svg_markup ): string {
+ if ( ! is_string( $svg_markup ) ) {
+ return 'data:image/svg+xml,';
+ }
+
+ return 'data:image/svg+xml,' . rawurlencode( $svg_markup );
+}
+
/**
* Remove corrected posts
*
diff --git a/package.json b/package.json
index 093cd052a..0cb1e6217 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "accessibility-checker",
- "version": "1.46.0",
+ "version": "1.47.0",
"description": "Audit and check your website for accessibility before you hit publish. In-post accessibility scanner and guidance.",
"author": "Equalize Digital",
"license": "GPL-2.0+",
diff --git a/readme.txt b/readme.txt
index c16fea1d7..8854adaec 100644
--- a/readme.txt
+++ b/readme.txt
@@ -1,9 +1,9 @@
=== Equalize Digital Accessibility Checker - WCAG, ADA, EAA and Section 508 compliance ===
Contributors: equalizedigital, alh0319, stevejonesdev
Tags: accessibility, EAA, WCAG, ADA, WP accessibility
-Requires at least: 6.7
+Requires at least: 6.8
Tested up to: 7.0
-Stable tag: 1.46.0
+Stable tag: 1.47.0
Requires PHP: 7.4
License: GPL-2.0-or-later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
@@ -279,6 +279,16 @@ You can report security bugs through the Patchstack Vulnerability Disclosure Pro
== Changelog ==
+2026-07-14 - version 1.47.0
+* Updated - the frontend highlighter now draws a white ring around the outline so highlighted elements stay visible on any background color.
+* Updated - the empty alt text check no longer flags 1x1 tracking pixels.
+* Updated - the link protocol checks now handle href values that contain leading whitespace.
+* Updated - SVG code snippets in the Accessibility Analysis panel are now rendered as images instead of raw markup.
+* Fix - scan results for the homepage are now stored against the homepage instead of the first blog post when the site is set to show latest posts.
+* Fix - the Simplified Summary text areas in the editor sidebar now have a label that assistive technology can announce.
+* Fix - corrected a caching issue that could cause database table name lookups to return the wrong table.
+* Remove - deleted an unused internal helper function.
+
2026-07-08 - version 1.46.0
* Updated - the aria-hidden rule no longer flags core Cover blocks.
* Updated - the Incorrect Heading Order summary no longer claims specific heading levels that may not match the actual issue.
diff --git a/src/frontendHighlighterApp/sass/app.scss b/src/frontendHighlighterApp/sass/app.scss
index db48f0d6d..97fbb1803 100644
--- a/src/frontendHighlighterApp/sass/app.scss
+++ b/src/frontendHighlighterApp/sass/app.scss
@@ -49,8 +49,9 @@ body {
&-element-selected {
outline: dashed 4px transparent !important;
- outline-offset: 5px !important;
+ outline-offset: 2px !important;
outline-color: magenta !important;
+ box-shadow: 0 0 0 8px variables.$color-white !important;
&-min-width {
min-width: 25px !important;
diff --git a/src/pageScanner/checks/img-alt-empty-check.js b/src/pageScanner/checks/img-alt-empty-check.js
index b49ae1323..dd4ac40f9 100644
--- a/src/pageScanner/checks/img-alt-empty-check.js
+++ b/src/pageScanner/checks/img-alt-empty-check.js
@@ -24,6 +24,11 @@ export default {
return true;
}
+ // Skip 1x1 tracking pixels (any src or base64)
+ if ( hasEmptyAlt && isTrackingPixel( node ) ) {
+ return true;
+ }
+
// Return false if alt is empty and none of the exceptions apply
return ! hasEmptyAlt;
},
@@ -84,6 +89,33 @@ function isInsideValidCaption( node ) {
return false;
}
+/**
+ * Check if image is a 1x1 tracking pixel.
+ * Checks both HTML attributes and computed natural dimensions.
+ * @param {HTMLElement} node - The node to check
+ * @return {boolean} True if image is a 1x1 tracking pixel
+ */
+function isTrackingPixel( node ) {
+ // Check HTML width/height attributes
+ const widthAttr = node.getAttribute( 'width' );
+ const heightAttr = node.getAttribute( 'height' );
+ if ( widthAttr === '1' && heightAttr === '1' ) {
+ return true;
+ }
+
+ // Check computed natural dimensions (e.g. for base64 or loaded images without explicit attributes)
+ if (
+ typeof node.naturalWidth === 'number' &&
+ typeof node.naturalHeight === 'number' &&
+ node.naturalWidth === 1 &&
+ node.naturalHeight === 1
+ ) {
+ return true;
+ }
+
+ return false;
+}
+
/**
* Check if image should be ignored due to plugin-specific cases
* @param {HTMLElement} node - The node to check
diff --git a/src/pageScanner/checks/link-has-valid-href-or-role.js b/src/pageScanner/checks/link-has-valid-href-or-role.js
index 7cdce72b5..19070e5b2 100644
--- a/src/pageScanner/checks/link-has-valid-href-or-role.js
+++ b/src/pageScanner/checks/link-has-valid-href-or-role.js
@@ -50,21 +50,22 @@ export default {
}
const trimmedHref = href ? href.trim() : '';
+ const normalizedHref = trimmedHref.toLowerCase();
// Fail if href is missing, just '#', or contains invalid protocols
- if ( ! href ||
+ if ( ! trimmedHref ||
trimmedHref === '#' ||
- href.toLowerCase().startsWith( 'javascript:' ) ||
- href.toLowerCase().startsWith( 'data:' ) ||
- href.toLowerCase().startsWith( 'file:' )
+ normalizedHref.startsWith( 'javascript:' ) ||
+ normalizedHref.startsWith( 'data:' ) ||
+ normalizedHref.startsWith( 'file:' )
) {
return false;
}
// Optionally validate URL format if it's an absolute URL
- if ( href.includes( '://' ) ) {
+ if ( trimmedHref.includes( '://' ) ) {
try {
- new URL( href );
+ new URL( trimmedHref );
} catch ( e ) {
return false; // Invalid URL formats
}
diff --git a/src/sidebar/components/Panels/ReadabilityAnalysis.js b/src/sidebar/components/Panels/ReadabilityAnalysis.js
index 120d08d1e..1c1e2cac0 100644
--- a/src/sidebar/components/Panels/ReadabilityAnalysis.js
+++ b/src/sidebar/components/Panels/ReadabilityAnalysis.js
@@ -362,6 +362,8 @@ const ReadabilityAnalysis = () => {
{ __( 'Simplified Summary', 'accessibility-checker' ) }
{
{ __( 'Simplified Summary', 'accessibility-checker' ) }
{
shouldPass: true,
},
+ // Tracking pixel (1x1) edge cases
+ {
+ name: 'should pass for 1x1 tracking pixel with empty alt (gif)',
+ html: '
',
+ shouldPass: true,
+ },
+ {
+ name: 'should pass for 1x1 tracking pixel with empty alt (arbitrary src)',
+ html: '
',
+ shouldPass: true,
+ },
+ {
+ name: 'should fail for 2x1 image with empty alt (not a tracking pixel)',
+ html: '
',
+ shouldPass: false,
+ },
+ {
+ name: 'should fail for img with empty alt and no dimension attributes (cannot confirm tracking pixel without dimensions)',
+ html: '
',
+ shouldPass: false,
+ },
+
// Button context - image with empty alt inside button with accessible name
{
name: 'should pass for img with empty alt inside button with aria-label',
diff --git a/tests/jest/rules/linkImproper.test.js b/tests/jest/rules/linkImproper.test.js
index a4381598f..ee9248b35 100644
--- a/tests/jest/rules/linkImproper.test.js
+++ b/tests/jest/rules/linkImproper.test.js
@@ -39,6 +39,21 @@ describe( 'Link Improper Rule', () => {
html: 'Bad practice',
shouldPass: false,
},
+ {
+ name: 'Fails when anchor has javascript: href with leading whitespace',
+ html: 'Bad practice',
+ shouldPass: false,
+ },
+ {
+ name: 'Fails when anchor href contains only whitespace',
+ html: 'Whitespace link',
+ shouldPass: false,
+ },
+ {
+ name: 'Fails when anchor href contains only whitespace',
+ html: 'Whitespace link',
+ shouldPass: false,
+ },
{
name: 'Fails when anchor has malformed URL',
html: 'Invalid URL',
diff --git a/tests/phpunit/Admin/EnqueueAdminTest.php b/tests/phpunit/Admin/EnqueueAdminTest.php
index 09186d0be..b08173897 100644
--- a/tests/phpunit/Admin/EnqueueAdminTest.php
+++ b/tests/phpunit/Admin/EnqueueAdminTest.php
@@ -461,6 +461,147 @@ public function testSrOnlyFormatDoesNotEnqueueOnOtherAdminPages() {
}
+ /**
+ * Test that edac_filter_admin_post_id overrides the post ID localized into edac_script_vars.
+ *
+ * @return void
+ */
+ public function testAdminPostIdFilterOverridesLocalizedPostId() {
+ global $post, $pagenow, $wp_scripts;
+
+ $original_post = $this->factory()->post->create_and_get();
+ $alternate_post = $this->factory()->post->create_and_get();
+ $post = $original_post;
+ $pagenow = 'post.php';
+
+ $filter_callback = static function () use ( $alternate_post ) {
+ return $alternate_post->ID;
+ };
+ add_filter( 'edac_filter_admin_post_id', $filter_callback );
+
+ $this->enqueue_admin::maybe_enqueue_admin_and_editor_app_scripts();
+
+ remove_filter( 'edac_filter_admin_post_id', $filter_callback );
+
+ $localized_data = $wp_scripts->get_data( 'edac', 'data' );
+ $this->assertStringContainsString( (string) $alternate_post->ID, $localized_data );
+ }
+
+ /**
+ * When the filter flags a latest-posts homepage (show_on_front=posts), the scan URL
+ * uses get_home_url() rather than an invalid preview link.
+ *
+ * @return void
+ */
+ public function testScanUrlUsesHomeUrlWhenLatestPostsHomeFilterReturnsTrue() {
+ global $post, $pagenow, $wp_scripts;
+
+ $post = $this->factory()->post->create_and_get( [ 'post_type' => 'page' ] );
+ $pagenow = 'post.php';
+
+ update_option( 'show_on_front', 'posts' );
+ update_option( 'page_for_posts', 0 );
+
+ $filter_callback = static function () {
+ return true;
+ };
+ add_filter( 'edac_filter_post_is_latest_posts_home', $filter_callback );
+
+ $this->enqueue_admin::maybe_enqueue_admin_and_editor_app_scripts();
+
+ remove_filter( 'edac_filter_post_is_latest_posts_home', $filter_callback );
+ delete_option( 'show_on_front' );
+ delete_option( 'page_for_posts' );
+
+ $localized_data = $wp_scripts->get_data( 'edac-editor-app', 'data' );
+ $this->assertStringContainsString( 'edac_pageScanner', $localized_data );
+ $this->assertStringNotContainsString( 'preview=true', $localized_data );
+ // The scan URL should be based on the home URL, not a preview link.
+ // In WP 6.9 forward slashes are no longer escaped in json_encode output; handle both forms.
+ // See: https://github.com/WordPress/wordpress-develop/pull/9557.
+ $expected_home = esc_url_raw( trailingslashit( get_home_url() ) );
+ if ( version_compare( get_bloginfo( 'version' ), '6.9', '<' ) ) {
+ $expected_home = str_replace( '/', '\\/', $expected_home );
+ }
+ $this->assertStringContainsString( $expected_home, $localized_data );
+ }
+
+ /**
+ * Fallback case: show_on_front=page with no static front page configured also
+ * counts as a latest-posts homepage, so the scan URL still uses get_home_url().
+ *
+ * @return void
+ */
+ public function testScanUrlUsesHomeUrlWhenShowOnFrontIsPageWithNoFrontPageConfigured() {
+ global $post, $pagenow, $wp_scripts;
+
+ $post = $this->factory()->post->create_and_get( [ 'post_type' => 'page' ] );
+ $pagenow = 'post.php';
+
+ update_option( 'show_on_front', 'page' );
+ delete_option( 'page_on_front' ); // No static front page configured — WP falls back to latest posts.
+
+ $filter_callback = static function () {
+ return true;
+ };
+ add_filter( 'edac_filter_post_is_latest_posts_home', $filter_callback );
+
+ $this->enqueue_admin::maybe_enqueue_admin_and_editor_app_scripts();
+
+ remove_filter( 'edac_filter_post_is_latest_posts_home', $filter_callback );
+ delete_option( 'show_on_front' );
+
+ $localized_data = $wp_scripts->get_data( 'edac-editor-app', 'data' );
+ $this->assertStringContainsString( 'edac_pageScanner', $localized_data );
+ $this->assertStringNotContainsString( 'preview=true', $localized_data );
+ // The scan URL should be based on the home URL, not a preview link.
+ // In WP 6.9 forward slashes are no longer escaped in json_encode output; handle both forms.
+ // See: https://github.com/WordPress/wordpress-develop/pull/9557.
+ $expected_home = esc_url_raw( trailingslashit( get_home_url() ) );
+ if ( version_compare( get_bloginfo( 'version' ), '6.9', '<' ) ) {
+ $expected_home = str_replace( '/', '\\/', $expected_home );
+ }
+ $this->assertStringContainsString( $expected_home, $localized_data );
+ }
+
+ /**
+ * The 'active' flag must reflect the filtered post ID's type: when the filter returns a
+ * non-scannable post type, $active is false so the scanner doesn't run.
+ *
+ * @return void
+ */
+ public function testActiveReflectsFilteredPostIdPostType() {
+ global $post, $pagenow, $wp_scripts;
+
+ // Global $post is a 'post' type (scannable under current option).
+ $scannable_post = $this->factory()->post->create_and_get( [ 'post_type' => 'post' ] );
+ // The filter will return a 'page' ID; make 'page' non-scannable for this test.
+ $non_scannable_post = $this->factory()->post->create_and_get( [ 'post_type' => 'page' ] );
+ $post = $scannable_post;
+ $pagenow = 'post.php';
+
+ // Restrict scannable types to 'post' only so 'page' becomes non-scannable.
+ update_option( 'edac_post_types', [ 'post' ] );
+
+ $filter_callback = static function () use ( $non_scannable_post ) {
+ return $non_scannable_post->ID;
+ };
+ add_filter( 'edac_filter_admin_post_id', $filter_callback );
+
+ $this->enqueue_admin::maybe_enqueue_admin_and_editor_app_scripts();
+
+ remove_filter( 'edac_filter_admin_post_id', $filter_callback );
+ // Restore original scannable post types.
+ update_option( 'edac_post_types', [ 'post', 'page' ] );
+
+ $localized_data = $wp_scripts->get_data( 'edac-editor-app', 'data' );
+ $this->assertNotEmpty( $localized_data );
+ // $active must be false because the filtered post type ('page') is not scannable.
+ // wp_localize_script serializes PHP false as "" (empty string) in older WP versions
+ // and may serialize it as JSON false in newer ones — accept both forms.
+ $this->assertMatchesRegularExpression( '/"active"\s*:\s*(false|"")/', $localized_data );
+ }
+
/**
* Helper to set a mock current screen with block editor context.
*
diff --git a/tests/phpunit/Admin/HelpersLoopbackTest.php b/tests/phpunit/Admin/HelpersLoopbackTest.php
deleted file mode 100644
index 503b1159d..000000000
--- a/tests/phpunit/Admin/HelpersLoopbackTest.php
+++ /dev/null
@@ -1,245 +0,0 @@
-assertIsBool( $result );
- $this->assertSame( $expected, $result );
- }
-
- /**
- * Data provider for test_is_domain_loopback.
- */
- public function domain_loopback_data() {
- return [
- 'localhost' => [
- 'domain' => 'localhost',
- 'expected' => true,
- ],
- '127.0.0.1 direct IP' => [
- 'domain' => '127.0.0.1',
- 'expected' => true,
- ],
- '127.0.0.2 loopback range' => [
- 'domain' => '127.0.0.2',
- 'expected' => true,
- ],
- '127.255.255.255 loopback range end' => [
- 'domain' => '127.255.255.255',
- 'expected' => true,
- ],
- '127.100.50.25 mid loopback range' => [
- 'domain' => '127.100.50.25',
- 'expected' => true,
- ],
- 'google.com external domain' => [
- 'domain' => 'google.com',
- 'expected' => false,
- ],
- 'example.com external domain' => [
- 'domain' => 'example.com',
- 'expected' => false,
- ],
- '192.168.1.1 private IP (not loopback)' => [
- 'domain' => '192.168.1.1',
- 'expected' => false,
- ],
- '10.0.0.1 private IP (not loopback)' => [
- 'domain' => '10.0.0.1',
- 'expected' => false,
- ],
- '8.8.8.8 public IP' => [
- 'domain' => '8.8.8.8',
- 'expected' => false,
- ],
- ];
- }
-
- /**
- * Test IPv4 loopback range boundaries.
- */
- public function test_ipv4_loopback_boundaries() {
- // Test the exact start of loopback range.
- $this->assertTrue( Helpers::is_domain_loopback( '127.0.0.0' ) );
-
- // Test just before loopback range.
- $this->assertFalse( Helpers::is_domain_loopback( '126.255.255.255' ) );
-
- // Test just after loopback range.
- $this->assertFalse( Helpers::is_domain_loopback( '128.0.0.0' ) );
- }
-
- /**
- * Test edge cases and invalid inputs.
- */
- public function test_edge_cases() {
- // Empty string.
- $result = Helpers::is_domain_loopback( '' );
- $this->assertIsBool( $result );
- $this->assertFalse( $result );
-
- // Invalid domain format.
- $result = Helpers::is_domain_loopback( 'not-a-domain' );
- $this->assertIsBool( $result );
-
- // Malformed IP.
- $result = Helpers::is_domain_loopback( '999.999.999.999' );
- $this->assertIsBool( $result );
- $this->assertFalse( $result );
-
- // Domain with protocol.
- $result = Helpers::is_domain_loopback( 'http://localhost' );
- $this->assertIsBool( $result );
- }
-
- /**
- * Test special localhost variations.
- */
- public function test_localhost_variations() {
- // Standard localhost should resolve to loopback.
- $this->assertTrue( Helpers::is_domain_loopback( 'localhost' ) );
-
- // Test case sensitivity (if applicable).
- $result = Helpers::is_domain_loopback( 'LOCALHOST' );
- $this->assertIsBool( $result );
-
- // Test with port (should still work since we're testing the domain part).
- $result = Helpers::is_domain_loopback( 'localhost:8080' );
- $this->assertIsBool( $result );
- }
-
- /**
- * Test that the method handles DNS resolution errors gracefully.
- */
- public function test_dns_resolution_error_handling() {
- // Test with a clearly non-existent domain.
- $fake_domain = 'definitely-not-a-real-domain-' . uniqid() . '.invalid';
- $result = Helpers::is_domain_loopback( $fake_domain );
-
- // Should return a boolean and not throw an exception.
- $this->assertIsBool( $result );
- $this->assertFalse( $result );
- }
-
- /**
- * Test IPv6 loopback detection (if supported).
- */
- public function test_ipv6_loopback_detection() {
- // The method checks for IPv6 AAAA records.
- // This test may be limited by the test environment's DNS capabilities.
-
- // Test direct IPv6 loopback address (may not work in all environments).
- $result = Helpers::is_domain_loopback( '::1' );
- $this->assertIsBool( $result );
-
- // If IPv6 is supported, ::1 should be detected as loopback.
- // However, we can't guarantee this in all test environments.
- }
-
- /**
- * Test that the method properly validates IP address formats.
- */
- public function test_ip_address_validation() {
- // Valid IPv4 addresses in loopback range.
- $valid_loopback_ips = [
- '127.0.0.1',
- '127.1.2.3',
- '127.254.255.254',
- ];
-
- foreach ( $valid_loopback_ips as $ip ) {
- $result = Helpers::is_domain_loopback( $ip );
- $this->assertTrue( $result, "Failed to detect loopback for IP: $ip" );
- }
-
- // Valid IPv4 addresses outside loopback range.
- $non_loopback_ips = [
- '1.1.1.1',
- '8.8.8.8',
- '192.168.1.1',
- '172.16.0.1',
- '203.0.113.1',
- ];
-
- foreach ( $non_loopback_ips as $ip ) {
- $result = Helpers::is_domain_loopback( $ip );
- $this->assertFalse( $result, "Incorrectly detected loopback for IP: $ip" );
- }
- }
-
- /**
- * Test behavior with domains that might have multiple A records.
- */
- public function test_multiple_a_records() {
- // Some domains may have multiple A records.
- // The method should handle this correctly by checking the resolved IP.
-
- // Test a well-known domain that should resolve to non-loopback.
- $result = Helpers::is_domain_loopback( 'github.com' );
- $this->assertIsBool( $result );
- $this->assertFalse( $result );
- }
-
- /**
- * Test the method's handling of the gethostbyname function.
- */
- public function test_gethostbyname_behavior() {
- // gethostbyname returns the hostname unchanged if resolution fails.
- // The method should handle this case.
-
- $non_resolvable = 'non-resolvable-domain-' . uniqid() . '.invalid';
- $result = Helpers::is_domain_loopback( $non_resolvable );
-
- // Should return false for non-resolvable domains.
- $this->assertIsBool( $result );
- $this->assertFalse( $result );
- }
-
- /**
- * Test performance with multiple calls.
- */
- public function test_performance_multiple_calls() {
- // Test that the method performs reasonably with multiple calls.
- $domains = [
- 'localhost',
- '127.0.0.1',
- 'google.com',
- 'example.com',
- '127.0.0.2',
- ];
-
- $start_time = microtime( true );
-
- foreach ( $domains as $domain ) {
- $result = Helpers::is_domain_loopback( $domain );
- $this->assertIsBool( $result );
- }
-
- $end_time = microtime( true );
- $execution_time = $end_time - $start_time;
-
- // Should complete within a reasonable time (5 seconds).
- $this->assertLessThan( 5.0, $execution_time, 'Method took too long to execute multiple calls' );
- }
-}
diff --git a/tests/phpunit/helper-functions/GetValidTableNameTest.php b/tests/phpunit/helper-functions/GetValidTableNameTest.php
new file mode 100644
index 000000000..a1421b8a5
--- /dev/null
+++ b/tests/phpunit/helper-functions/GetValidTableNameTest.php
@@ -0,0 +1,53 @@
+get_charset_collate();
+ $main_table = $wpdb->prefix . 'accessibility_checker';
+ $this->alt_table = $wpdb->posts;
+
+ require_once ABSPATH . 'wp-admin/includes/upgrade.php';
+
+ $db_schema = "CREATE TABLE %s (
+ id bigint(20) NOT NULL AUTO_INCREMENT,
+ PRIMARY KEY (id)
+ ) $charset_collate;";
+
+ dbDelta( sprintf( $db_schema, $main_table ) );
+ }
+
+ /**
+ * Ensures the helper validates different tables without returning cached results.
+ */
+ public function test_returns_requested_table_name_each_time() {
+ global $wpdb;
+
+ $main_table = $wpdb->prefix . 'accessibility_checker';
+
+ $this->assertSame( $main_table, edac_get_valid_table_name( $main_table ) );
+ $this->assertSame( $this->alt_table, edac_get_valid_table_name( $this->alt_table ) );
+ }
+}
diff --git a/tests/phpunit/helper-functions/SvgMarkupToDataUriTest.php b/tests/phpunit/helper-functions/SvgMarkupToDataUriTest.php
new file mode 100644
index 000000000..13c679e47
--- /dev/null
+++ b/tests/phpunit/helper-functions/SvgMarkupToDataUriTest.php
@@ -0,0 +1,105 @@
+';
+
+ $this->assertSame(
+ 'data:image/svg+xml,' . rawurlencode( $svg ),
+ edac_svg_markup_to_data_uri( $svg )
+ );
+ }
+
+ /**
+ * Tests that no raw markup characters survive encoding - the whole point
+ * is that the string is inert HTML once placed in an
attribute.
+ *
+ * @dataProvider malicious_svg_data
+ *
+ * @param string $svg The malicious SVG markup to encode.
+ */
+ public function test_strips_no_bytes_but_leaves_no_raw_markup_characters( $svg ) {
+ $data_uri = edac_svg_markup_to_data_uri( $svg );
+
+ $this->assertStringStartsWith( 'data:image/svg+xml,', $data_uri );
+ $this->assertStringNotContainsString( '<', $data_uri );
+ $this->assertStringNotContainsString( '>', $data_uri );
+ $this->assertStringNotContainsString( '"', $data_uri );
+ $this->assertStringNotContainsString( "'", $data_uri );
+
+ // The encoded payload still round-trips back to the original markup.
+ $this->assertSame(
+ $svg,
+ rawurldecode( substr( $data_uri, strlen( 'data:image/svg+xml,' ) ) )
+ );
+ }
+
+ /**
+ * Data provider of SVG markup containing common XSS vectors.
+ */
+ public function malicious_svg_data() {
+ return [
+ 'onload handler' => [ '' ],
+ 'script child' => [ '' ],
+ 'foreignObject' => [ '' ],
+ 'javascript: xlink' => [ '' ],
+ 'animate onbegin' => [ '' ],
+ 'combined all-in-one' => [
+ '',
+ ],
+ ];
+ }
+
+ /**
+ * Tests that the combined-vector SVG's dangerous constructs survive
+ * encoding intact (this function encodes, it does not sanitize - the
+ * caller's
context is what neutralizes them, not this string).
+ */
+ public function test_combined_vector_payload_is_preserved_not_stripped() {
+ $svg = '';
+ $decoded = rawurldecode( substr( edac_svg_markup_to_data_uri( $svg ), strlen( 'data:image/svg+xml,' ) ) );
+
+ $this->assertSame( $svg, $decoded );
+ $this->assertStringContainsString( 'onload="alert(1)"', $decoded );
+ $this->assertStringContainsString( '', $decoded );
+ $this->assertStringContainsString( 'onclick="alert(3)"', $decoded );
+ $this->assertStringContainsString( '', $decoded );
+ }
+
+ /**
+ * Tests that non-string input never fatals and always yields a bare,
+ * payload-less data URI rather than attempting to encode it.
+ *
+ * @dataProvider non_string_data
+ *
+ * @param mixed $value A non-string value.
+ */
+ public function test_non_string_input_returns_bare_data_uri( $value ) {
+ $this->assertSame( 'data:image/svg+xml,', edac_svg_markup_to_data_uri( $value ) );
+ }
+
+ /**
+ * Data provider of non-string values.
+ */
+ public function non_string_data() {
+ return [
+ 'null' => [ null ],
+ 'array' => [ [ '' ] ],
+ 'int' => [ 42 ],
+ 'bool' => [ true ],
+ 'object' => [ (object) [ 'markup' => '' ] ],
+ ];
+ }
+}
diff --git a/tests/phpunit/includes/classes/EnqueueFrontendTest.php b/tests/phpunit/includes/classes/EnqueueFrontendTest.php
index 0e73e0857..3df6c0029 100644
--- a/tests/phpunit/includes/classes/EnqueueFrontendTest.php
+++ b/tests/phpunit/includes/classes/EnqueueFrontendTest.php
@@ -73,6 +73,58 @@ public function testScannerBundleUrlIncludesVersionQueryString(): void {
$this->assertStringContainsString( 'ver=' . EDAC_VERSION, $localized_data );
}
+ /**
+ * Highlighter must NOT load on the "latest posts" homepage when no filter overrides the ID.
+ *
+ * When show_on_front=posts, the global $post is the first blog post from the main query, not
+ * the homepage itself. The fix passes null to the filter so the free plugin bails gracefully.
+ */
+ public function testFrontendHighlighterDoesNotLoadOnLatestPostsHomepage(): void {
+ $admin_id = $this->factory()->user->create( [ 'role' => 'administrator' ] );
+ wp_set_current_user( $admin_id );
+
+ // Create a post so the main query has results.
+ $this->factory()->post->create_and_get( [ 'post_type' => 'post' ] );
+
+ update_option( 'show_on_front', 'posts' );
+
+ // Simulate visiting the homepage so is_home() / is_front_page() return true.
+ $this->go_to( '/' );
+
+ Enqueue_Frontend::maybe_enqueue_frontend_highlighter();
+
+ $this->assertFalse( wp_script_is( 'edac-frontend-highlighter-app', 'enqueued' ) );
+
+ delete_option( 'show_on_front' );
+ }
+
+ /**
+ * A filter on edac_filter_frontend_highlight_post_id can enable the highlighter on the
+ * "latest posts" homepage by supplying a valid post ID (e.g. a Pro virtual-page ID).
+ */
+ public function testFrontendHighlighterLoadsOnLatestPostsHomepageWhenFilterProvidesId(): void {
+ $admin_id = $this->factory()->user->create( [ 'role' => 'administrator' ] );
+ wp_set_current_user( $admin_id );
+
+ $post = $this->factory()->post->create_and_get( [ 'post_type' => 'post' ] );
+
+ update_option( 'show_on_front', 'posts' );
+
+ $this->go_to( '/' );
+
+ $filter_callback = static function () use ( $post ) {
+ return $post->ID;
+ };
+ $this->added_filters['edac_filter_frontend_highlight_post_id'] = $filter_callback;
+ add_filter( 'edac_filter_frontend_highlight_post_id', $filter_callback );
+
+ Enqueue_Frontend::maybe_enqueue_frontend_highlighter();
+
+ $this->assertTrue( wp_script_is( 'edac-frontend-highlighter-app', 'enqueued' ) );
+
+ delete_option( 'show_on_front' );
+ }
+
/**
* Helper: enqueue the frontend highlighter as an admin and return the localized data string.
*