format( $number );
} else {
if ( $number % 100 >= 11 && $number % 100 <= 13 ) {
$ordinal = $number . 'th';
} else {
switch ( $number % 10 ) {
case 1:
$ordinal = $number . 'st';
break;
case 2:
$ordinal = $number . 'nd';
break;
case 3:
$ordinal = $number . 'rd';
break;
default:
$ordinal = $number . 'th';
break;
}
}
return $ordinal;
}
}
/**
* Remove element from multi-dimensional array
*
* @param array $items The multi-dimensional array.
* @param string $key The key of the element.
* @param string $value The value of the element.
* @return array
*/
function edac_remove_element_with_value( $items, $key, $value ) {
foreach ( $items as $sub_key => $sub_array ) {
if ( $sub_array[ $key ] === $value ) {
unset( $items[ $sub_key ] );
}
}
return $items;
}
/**
* Filter a multi-dimensional array
*
* @param array $items The multi-dimensional array.
* @param string $index The index of the element.
* @param string $value The element value to match.
* @return array
*/
function edac_filter_by_value( $items, $index, $value ) {
if ( is_array( $items ) && count( $items ) > 0 ) {
foreach ( array_keys( $items ) as $key ) {
$temp[ $key ] = $items[ $key ][ $index ];
if ( $temp[ $key ] === $value ) {
$newarray[ $key ] = $items[ $key ];
}
}
}
if ( isset( $newarray ) && is_array( $newarray ) && count( $newarray ) ) {
return array_values( $newarray );
}
return [];
}
/**
* Get days plugin has been active
*
* @return int
*/
function edac_days_active() {
$activation_date = get_option( 'edac_activation_date' );
if ( $activation_date ) {
$diff = strtotime( $activation_date ) - strtotime( gmdate( 'Y-m-d H:i:s' ) );
return abs( round( $diff / 86400 ) );
}
return 0;
}
/**
* Custom Post Types
*
* Returns only non-built-in post types that are publicly viewable, meaning
* both public and publicly_queryable are true. Post types that redirect
* frontend requests (publicly_queryable = false) are excluded so they do
* not appear as scan targets in settings.
*
* @return array
*/
function edac_custom_post_types() {
$args = [
'public' => true,
'publicly_queryable' => true,
'_builtin' => false,
];
$output = 'names'; // names or objects, note names is the default.
$operator = 'and'; // Options 'and' or 'or'.
return get_post_types( $args, $output, $operator );
}
/**
* Available Post Types
*
* @return array
*/
function edac_post_types() {
/**
* Filter the post types that the plugin will check.
*
* @since 1.4.0
*
* @param array $post_types post types.
*/
$post_types = apply_filters( 'edac_filter_post_types', [ 'post', 'page' ] );
if ( ! is_array( $post_types ) ) {
$post_types = [ $post_types ];
}
// remove duplicates.
$post_types = array_unique( $post_types );
// validate post types.
foreach ( $post_types as $key => $post_type ) {
if ( ! post_type_exists( $post_type ) ) {
unset( $post_types[ $key ] );
}
}
return $post_types;
}
/**
* Retrieve a human readable post type label.
*
* @param string $post_type Post type slug.
* @return string
*/
function edac_get_post_type_label( string $post_type ): string {
$post_type = sanitize_key( (string) $post_type );
if ( '' === $post_type ) {
return '';
}
$post_type_object = get_post_type_object( $post_type );
if ( $post_type_object instanceof \WP_Post_Type && ! empty( $post_type_object->labels->name ) ) {
return $post_type_object->labels->name;
}
return ucfirst( $post_type );
}
/**
* This function validates a table name against WordPress naming conventions and checks its existence in the database.
*
* The function first checks if the provided table name only contains alphanumeric characters, underscores, or hyphens.
* If not, it returns null.
*
* After that, it checks if a table with that name actually exists in the database using the SHOW TABLES LIKE query.
* If the table doesn't exist, it also returns null.
*
* If both checks are passed, it returns the valid table name.
*
* @param string $table_name The name of the table to be validated.
*
* @return string|null The validated table name, or null if the table name is invalid or the table does not exist.
*/
function edac_get_valid_table_name( $table_name ) {
global $wpdb;
static $found_table_name;
if ( isset( $found_table_name ) ) {
return $found_table_name;
}
// Check if table name only contains alphanumeric characters, underscores, or hyphens.
if ( ! preg_match( '/^[a-zA-Z0-9_\-]+$/', $table_name ) ) {
// Invalid table name.
return null;
}
// Verify that the table actually exists in the database.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_name ) ) !== $table_name ) {
// Table does not exist.
return null;
}
$found_table_name = $table_name;
return $found_table_name;
}
/**
* Upcoming meetups in json format
*
* @param string $meetup meetup name.
* @param integer $count number of meetups to return.
* @return array
*/
function edac_get_upcoming_meetups_json( $meetup, $count = 5 ) {
if ( empty( $meetup ) || ! is_string( $meetup ) ) {
return [];
}
// Min of 1 and max of 25.
$count = absint( max( 1, min( 25, $count ) ) );
// Sanitize meetup name for both cache key and GraphQL query to prevent injection.
$sanitized_meetup = sanitize_title( $meetup );
$key = '_upcoming_meetups__' . $sanitized_meetup . '__' . (int) $count;
$stale_key = $key . '__stale';
$cached_value = get_transient( $key );
if ( false !== $cached_value ) {
return is_array( $cached_value ) ? $cached_value : [];
}
$output = [];
$request_uri = 'https://api.meetup.com/gql-ext';
$query = '
query Group {
groupByUrlname(urlname: "' . $sanitized_meetup . '") {
events(first: ' . (int) $count . ') {
totalCount
edges {
node {
dateTime
eventUrl
id
title
}
}
}
}
}';
$request = wp_remote_post(
$request_uri,
[
'headers' => [
'Content-Type' => 'application/json',
],
'timeout' => 10, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- Timeout set for external request.
'body' => wp_json_encode(
[
'query' => $query,
]
),
]
);
if ( ! is_wp_error( $request ) && 200 === (int) wp_remote_retrieve_response_code( $request ) ) {
$response_body = json_decode( wp_remote_retrieve_body( $request ) );
$edges = null;
if ( is_object( $response_body ) && isset( $response_body->data->groupByUrlname->events->edges ) ) {
$edges = $response_body->data->groupByUrlname->events->edges;
}
if ( is_array( $edges ) ) {
foreach ( $edges as $edge ) {
if ( ! isset( $edge->node ) ) {
continue;
}
$event = $edge->node;
// phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL response uses camelCase.
if ( empty( $event->title ) || empty( $event->dateTime ) || empty( $event->eventUrl ) || empty( $event->id ) ) {
continue;
}
$timestamp = strtotime( (string) $event->dateTime );
if ( false === $timestamp ) {
continue;
}
$event_data = new stdClass();
$event_data->name = (string) $event->title;
$event_data->time = $timestamp * 1000; // Convert to milliseconds to match old format.
$event_data->link = (string) $event->eventUrl;
$event_data->id = (string) $event->id;
// phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase.
$output[] = $event_data;
}
}
}
if ( ! empty( $output ) ) {
set_transient( $key, $output, DAY_IN_SECONDS );
update_option( $stale_key, $output, false );
return $output;
}
$stale_value = get_option( $stale_key );
if ( is_array( $stale_value ) && ! empty( $stale_value ) ) {
// Serve stale data for a short window while retrying upstream requests periodically.
set_transient( $key, $stale_value, HOUR_IN_SECONDS );
return $stale_value;
}
return [];
}
/**
* Upcoming meetups in html
*
* @param string $meetup meetup name.
* @param integer $count number of meetups to return.
* @param string $heading heading level.
* @return string
*/
function edac_get_upcoming_meetups_html( $meetup, $count = 5, $heading = '3' ) {
$json = edac_get_upcoming_meetups_json( $meetup, $count );
if ( empty( $json ) ) {
return '';
}
$html = '
';
return $html;
}
/**
* Calculate the issue density
*
* @param int $issue_count number of issues.
* @param int $element_count number of elements.
* @param int $content_length length of content.
* @return int
*/
function edac_get_issue_density( $issue_count, $element_count, $content_length ) {
if ( $element_count < 1 || $content_length < 1 ) {
return 0;
}
$element_weight = .8;
$content_weight = .2;
$error_elements_percentage = $issue_count / $element_count;
$error_content_percentage = $issue_count / $content_length;
$score = (
( $error_elements_percentage * $element_weight ) +
( $error_content_percentage * $content_weight )
);
return round( $score * 100, 2 );
}
/**
* Get simplified summary
*
* @param integer $post Post ID.
* @return void
*/
function edac_get_simplified_summary( $post = null ) {
if ( null === $post ) {
$post = get_the_ID();
}
if ( null === $post ) {
return;
}
echo wp_kses_post(
( new \EDAC\Inc\Simplified_Summary() )->simplified_summary_markup( $post )
);
}
/**
* Get Post Count by available custom post types
*
* @return mixed
*/
function edac_get_posts_count() {
$output = [];
$post_types = Settings::get_scannable_post_types();
if ( $post_types ) {
foreach ( $post_types as $post_type ) {
$counts = wp_count_posts( $post_type );
if ( $counts ) {
foreach ( $counts as $key => $value ) {
// phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual
if ( 0 == $value ) {
unset( $counts->{$key} );
}
}
}
if ( $counts ) {
$array = [];
foreach ( $counts as $key => $value ) {
$array[] = $key . ' = ' . $value;
}
if ( $array ) {
$output[] = $post_type . ': ' . implode( ', ', $array );
}
}
}
}
if ( $output ) {
return implode( ', ', $output );
}
return false;
}
/**
* Get Raw Global Error Count
*
* @return array
*/
function edac_get_error_count() {
global $wpdb;
// Define a unique cache key for our data.
$cache_key = 'edac_errors_' . get_current_blog_id();
$stored_errors = wp_cache_get( $cache_key );
// Check if the result exists in the cache.
if ( false === $stored_errors ) {
// If not, perform the database query.
$table_name = $wpdb->prefix . 'accessibility_checker';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$stored_errors = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT count(*) FROM %i WHERE siteid = %d AND ruletype = %s', $table_name, get_current_blog_id(), 'error' ) );
// Save the result in the cache for future use.
wp_cache_set( $cache_key, $stored_errors );
}
return $stored_errors;
}
/**
* Get Raw Global Warning Count
*
* @return array Array of.
*/
function edac_get_warning_count() {
global $wpdb;
// Define a unique cache key for our data.
$cache_key = 'edac_warnings_' . get_current_blog_id();
$stored_warnings = wp_cache_get( $cache_key );
// Check if the result exists in the cache.
if ( false === $stored_warnings ) {
// If not, perform the database query.
$table_name = $wpdb->prefix . 'accessibility_checker';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$stored_warnings = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT count(*) FROM %i WHERE siteid = %d AND ruletype = %s', $table_name, get_current_blog_id(), 'warning' ) );
// Save the result in the cache for future use.
wp_cache_set( $cache_key, $stored_warnings );
}
return $stored_warnings;
}
/**
* Get Database Table Count
*
* @param string $table Database table.
* @return int
*/
function edac_database_table_count( $table ) {
global $wpdb;
// Create a unique cache key based on the table's name.
$cache_key = 'edac_table_count_' . $table;
// Try to get the count from the cache first.
$count = wp_cache_get( $cache_key );
if ( false === $count ) {
// If the count is not in the cache, perform the database query.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$count = $wpdb->get_var( $wpdb->prepare( 'SELECT count(*) FROM %i', $wpdb->prefix . $table ) );
// Save the count to the cache for future use.
wp_cache_set( $cache_key, $count );
}
return $count;
}
/**
* Generate a summary statistic list item.
*
* @since 1.14.0
*
* @param string $item_class The base CSS class for the list item.
* @param int $count The count of items to display.
* @param string $label The translated label with count included.
* @param string $icon_name Icon name passed to edac_icon(). Default 'error'.
*
* @return string The generated HTML list item.
*/
function edac_generate_summary_stat( string $item_class, int $count, string $label, string $icon_name = 'error' ): string {
$has_error_class = ( $count > 0 ) ? ' has-errors' : '';
return '
' . $count . ' ' . $label . '
' . edac_icon( $icon_name ) . '
' . $count . '
' . $label . '
';
}
/**
* Generate links to pro page with some params.
*
* @param array $query_args A list of key value pairs to add as query vars to the link.
* @param string $type The type of link to generate. Default is 'pro'.
* @param array $args Additional arguments to pass on the link.
* @return string
*/
function edac_generate_link_type( $query_args = [], $type = 'pro', $args = [] ): string {
if ( ! is_array( $query_args ) ) {
$query_args = [];
}
if ( ! is_array( $args ) ) {
$args = [];
}
$date_now = new DateTime( gmdate( 'Y-m-d H:i:s' ) );
$activation_date = new DateTime( get_option( 'edac_activation_date', gmdate( 'Y-m-d H:i:s' ) ) );
$interval = $date_now->diff( $activation_date );
$days_active = $interval->days;
$query_defaults = [
'utm_source' => 'accessibility-checker',
'utm_medium' => 'software',
'utm_campaign' => 'wordpress-general',
'php_version' => PHP_VERSION,
'platform' => 'wordpress',
'platform_version' => $GLOBALS['wp_version'],
'software' => edac_is_pro() ? 'pro' : 'free',
'software_version' => defined( 'EDACP_VERSION' ) ? EDACP_VERSION : EDAC_VERSION,
'days_active' => $days_active,
];
// Add the ref parameter if one is set via filter.
$ref = apply_filters( 'edac_filter_generate_link_type_ref', '' );
if ( ! empty( $ref ) && is_string( $ref ) ) {
$query_args['ref'] = $ref;
}
$query_args = array_merge( $query_defaults, $query_args );
switch ( $type ) {
case 'help':
$base_link = trailingslashit( 'https://a11ychecker.com/help' . ( $args['help_id'] ?? '' ) );
break;
case 'custom': // phpcs:ignore -- intentially only breaking inside the condition because if it's not set we want to hit default.
if ( ! empty( $args['base_link'] ) ) {
$base_link = $args['base_link'];
break;
}
case 'pro':
default:
$base_link = 'https://equalizedigital.com/accessibility-checker/pricing/';
break;
}
return add_query_arg( $query_args, $base_link );
}
/**
* Echo or return a link with some utms.
*
* This is just a simplified wrapper around `edac_generate_link_type` to generate a link with UTM parameters.
*
* @param string $base_url the base URL to which UTM parameters will be added.
* @param string $campaign the UTM campaign name, optional.
* @param string $content the UTM content name, optional.
* @param bool $directly_echo whether to echo the link or return it. Default is true.
*
* @return void|string
*/
function edac_link_wrapper( $base_url, $campaign = '', $content = '', $directly_echo = true ) {
if ( empty( $base_url ) || ! is_string( $base_url ) ) {
return;
}
$params = [];
if ( ! empty( $campaign ) ) {
$params['utm_campaign'] = $campaign;
}
if ( ! empty( $content ) ) {
$params['utm_content'] = $content;
}
$link = edac_generate_link_type(
$params,
'custom',
[ 'base_link' => $base_url ]
);
if ( ! $directly_echo ) {
return $link;
}
echo esc_url( $link );
}
/**
* Check if WooCommerce is enabled.
*
* This just checks for existence of the main WooCommerce function and class.
*
* @return bool
*/
function edac_is_woocommerce_enabled() {
return function_exists( 'WC' ) && class_exists( 'WooCommerce' );
}
/**
* Check if a given post id is the WooCommerce checkout page.
*
* @param int $post_id The post ID to check.
* @return bool
*/
function edac_check_if_post_id_is_woocommerce_checkout_page( $post_id ) {
if ( ! edac_is_woocommerce_enabled() ) {
return false;
}
return wc_get_page_id( 'checkout' ) === $post_id;
}
/**
* Parse HTML content to extract image or SVG elements
*
* @param string $html The HTML content to parse.
* @return array Array containing 'img' (string) and 'svg' (string) keys.
*/
function edac_parse_html_for_media( $html ) {
if ( empty( $html ) ) {
return [
'img' => null,
'svg' => null,
];
}
// Decode HTML entities before processing.
$decoded_html = html_entity_decode( $html, ENT_QUOTES | ENT_HTML5 );
// Early return if no media tags found.
if ( stripos( $decoded_html, ' null,
'svg' => null,
];
}
// More specific img tag regex pattern.
if ( preg_match( '/ ]+src=([\'"])(.*?)\1[^>]*>/i', $decoded_html, $matches ) ) {
return [
'img' => $matches[2] ?? null,
'svg' => null,
];
}
// SVG pattern remains the same.
if ( preg_match( '/]*>.*?<\/svg>/is', $decoded_html, $matches ) ) {
return [
'img' => null,
'svg' => $matches[0],
];
}
return [
'img' => null,
'svg' => null,
];
}
/**
* Remove corrected posts
*
* @param int $post_ID The ID of the post.
* @param string $type The type of the post.
* @param int $pre The flag indicating the removal stage (1 for before validation php based rules, 2 for after validation).
* @param string $ruleset The type of the ruleset to correct (php or js). For backwards compatibility, defaults to 'php'.
*
* @return void
*/
function edac_remove_corrected_posts( $post_ID, $type, $pre = 1, $ruleset = 'php' ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- $ruleset is for backwards compatibility.
global $wpdb;
$rules = edac_register_rules();
if ( 0 === count( $rules ) ) {
return;
}
$sql = 1 === $pre
? "UPDATE {$wpdb->prefix}accessibility_checker SET recordcheck = %d WHERE siteid = %d AND postid = %d AND type = %s"
: "DELETE FROM {$wpdb->prefix}accessibility_checker WHERE recordcheck = %d AND siteid = %d AND postid = %d AND type = %s";
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Using direct query for adding data to database, caching not required for one time operation.
$wpdb->query(
$wpdb->prepare(
$sql, // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
0,
get_current_blog_id(),
$post_ID,
$type
)
);
}
/**
* Get the canonical landmark detection rules the scanner uses to populate the
* `landmark` database column.
*
* This is the single source of truth mirrored by `LANDMARK_TAGS`,
* `LANDMARK_ROLES`, `CONDITIONAL_LANDMARK_TAGS`, and
* `CONDITIONAL_LANDMARK_ROLES` in `src/pageScanner/index.js`. Tag names stay
* uppercase to match `Element.tagName`; role names stay lowercase to match
* the already-lowercased `role` attribute the scanner compares against.
*
* Tag-detected and role-detected landmarks are NOT normalized to a shared
* value even when conceptually the same landmark — e.g. a `` element
* saves `nav`, but `role="navigation"` saves `navigation`. Same for ``
* (`aside`) vs `role="complementary"` (`complementary`).
*
* Third-party code may extend the landmark set via the `edac_landmark_types`
* filter, but must return an array with all four keys intact.
*
* @since 1.44.0
*
* @return array{tags: string[], roles: string[], conditionalTags: string[], conditionalRoles: string[]} Landmark detection rules.
*/
function edac_get_landmark_types(): array {
$types = [
'tags' => [ 'MAIN', 'HEADER', 'FOOTER', 'NAV', 'ASIDE' ],
'roles' => [ 'main', 'navigation', 'banner', 'contentinfo', 'complementary' ],
'conditionalTags' => [ 'SECTION', 'ARTICLE', 'FORM' ],
'conditionalRoles' => [ 'region', 'article', 'form' ],
];
/**
* Filter the landmark types used by the scanner and Issues Explorer filter.
*
* @since 1.44.0
*
* @param array $types {
* @type string[] $tags Uppercase HTML tag names detected unconditionally.
* @type string[] $roles ARIA roles detected unconditionally.
* @type string[] $conditionalTags Uppercase HTML tag names detected only when the element has an accessible name.
* @type string[] $conditionalRoles ARIA roles detected only when the element has an accessible name.
* }
*/
$filtered = apply_filters( 'edac_landmark_types', $types );
return is_array( $filtered ) ? $filtered : $types;
}
/**
* Get the distinct landmark values that can actually be written to the
* `landmark` database column, as filter-friendly `{ value, label }` pairs.
*
* Built from `edac_get_landmark_types()` so the Issues Explorer's Landmark
* filter (in the pro plugin) can never drift from what the scanner persists.
*
* @since 1.44.0
*
* @return array Filter options, value lowercase, label human-readable.
*/
function edac_get_landmark_filter_options(): array {
$types = edac_get_landmark_types();
$values = array_unique(
array_map(
'strtolower',
array_merge(
$types['tags'] ?? [],
$types['roles'] ?? [],
$types['conditionalTags'] ?? [],
$types['conditionalRoles'] ?? []
)
)
);
$labels = [
/* translators: Landmark type label for the Issues Explorer filter. */
'main' => __( 'Main', 'accessibility-checker' ),
/* translators: Landmark type label for the Issues Explorer filter. */
'header' => __( 'Header', 'accessibility-checker' ),
/* translators: Landmark type label for the Issues Explorer filter. */
'footer' => __( 'Footer', 'accessibility-checker' ),
/* translators: Landmark type label for the Issues Explorer filter. */
'nav' => __( 'Nav', 'accessibility-checker' ),
/* translators: Landmark type label for the Issues Explorer filter. */
'aside' => __( 'Aside', 'accessibility-checker' ),
/* translators: Landmark type label for the Issues Explorer filter. */
'navigation' => __( 'Navigation', 'accessibility-checker' ),
/* translators: Landmark type label for the Issues Explorer filter. */
'banner' => __( 'Banner', 'accessibility-checker' ),
/* translators: Landmark type label for the Issues Explorer filter. */
'contentinfo' => __( 'Contentinfo', 'accessibility-checker' ),
/* translators: Landmark type label for the Issues Explorer filter. */
'complementary' => __( 'Complementary', 'accessibility-checker' ),
/* translators: Landmark type label for the Issues Explorer filter. */
'section' => __( 'Section', 'accessibility-checker' ),
/* translators: Landmark type label for the Issues Explorer filter. */
'article' => __( 'Article', 'accessibility-checker' ),
/* translators: Landmark type label for the Issues Explorer filter. */
'form' => __( 'Form', 'accessibility-checker' ),
/* translators: Landmark type label for the Issues Explorer filter. */
'region' => __( 'Region', 'accessibility-checker' ),
];
return array_map(
static function ( $value ) use ( $labels ) {
return [
'value' => $value,
'label' => $labels[ $value ] ?? ucfirst( $value ),
];
},
array_values( $values )
);
}
/**
* Generate a landmark link with proper URL and ARIA label
*
* @param string $landmark The landmark type (e.g., "header", "navigation", "main").
* @param string $landmark_selector The CSS selector for the landmark.
* @param int $post_id The post ID to link to.
* @param string $css_class Optional CSS class for the link. Default 'edac-details-rule-records-record-landmark-link'.
* @param bool $target_blank Whether to open link in new window. Default true.
*
* @return string The HTML for the landmark link or just the landmark text if no selector.
*/
function edac_generate_landmark_link( $landmark, $landmark_selector, $post_id, $css_class = 'edac-details-rule-records-record-landmark-link', $target_blank = true ) {
if ( empty( $landmark ) ) {
return '';
}
$landmark = ucwords( $landmark );
$landmark = esc_html( $landmark );
// If we have both landmark and selector, create a link.
if ( ! empty( $landmark_selector ) ) {
$link = apply_filters(
'edac_get_origin_url_for_virtual_page',
get_the_permalink( $post_id ),
$post_id
);
$landmark_url = add_query_arg(
[
'edac_landmark' => base64_encode( $landmark_selector ),
'edac_nonce' => wp_create_nonce( 'edac_highlight' ),
],
$link
);
// 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' ), $landmark );
$target_attr = $target_blank ? ' target="_blank"' : '';
return sprintf(
'%s ',
esc_url( $landmark_url ),
esc_attr( $css_class ),
$target_attr,
esc_attr( $landmark_aria_label ),
$landmark
);
}
// If we only have landmark text, return it formatted.
return $landmark;
}
/**
* Check if a post is a virtual page.
*
* This function checks if a post is a virtual page using the pro plugin's
* VirtualItemType:POST_TYPE constant.
*
* @param int $post_id The post ID to check.
* @return bool True if the post is a virtual page, false otherwise.
*/
function edac_is_virtual_page( $post_id ) {
if ( class_exists( '\EqualizeDigital\AccessibilityCheckerPro\VirtualContent\PostType\VirtualItemType' ) ) {
$post_type = get_post_type( $post_id );
$pro_post_type = \EqualizeDigital\AccessibilityCheckerPro\VirtualContent\PostType\VirtualItemType::POST_TYPE;
return $pro_post_type === $post_type;
}
return false;
}
/**
* Check if the Pro version of the plugin is active.
*
* @return bool True if Pro version is active, false otherwise.
*/
function edac_is_pro() {
return defined( 'EDACP_VERSION' ) && defined( 'EDAC_KEY_VALID' ) && EDAC_KEY_VALID;
}
/**
* Get current timestamp in UTC for database storage.
*
* @since 1.35.0
* @return string MySQL datetime string in UTC.
*/
function edac_get_current_utc_datetime(): string {
return gmdate( 'Y-m-d H:i:s' );
}
/**
* Format a UTC datetime string for display in WordPress timezone.
*
* Uses the WordPress configured date and time format settings.
*
* @since 1.35.0
* @param string $utc_datetime MySQL datetime string in UTC format.
* @return string Formatted datetime string in WordPress timezone, or empty string if invalid.
*/
function edac_format_datetime_from_utc( string $utc_datetime ): string {
if ( ! $utc_datetime || '0000-00-00 00:00:00' === $utc_datetime ) {
return '';
}
$timestamp = strtotime( $utc_datetime . ' UTC' );
if ( false === $timestamp ) {
return '';
}
$date_format = get_option( 'date_format' );
$time_format = get_option( 'time_format' );
$format = $date_format . ' ' . $time_format;
return wp_date( $format, $timestamp );
}
/**
* Normalize a raw Flesch-Kincaid grade level float to a whole-number grade.
*
* `floor()` alone collapses any FK value in (0, 1) to 0, which misrepresents
* very simple content as "not calculable." Values above 0 but below 1 are
* normalized to 1 so that compliance checks treat them correctly.
*
* @since 1.46.0
*
* @param float|bool|null $fk_grade Raw Flesch-Kincaid grade level returned by the library.
* @return int Normalized grade: 0 when the library returned 0, false, or null (not enough content),
* otherwise max(1, floor($fk_grade)).
*/
function edac_normalize_fk_grade( $fk_grade ): int {
$fk_grade = (float) $fk_grade;
if ( $fk_grade <= 0 ) {
return 0;
}
return max( 1, (int) floor( $fk_grade ) );
}
/**
* Determine the icon name to display for the readability panel.
*
* PHP equivalent of the JS getPanelIcon() helper used in the readability sidebar panel.
*
* Logic:
* - No content or grade is 0 → 'warning'
* - Reading level is passing (below 9th grade) → 'check'
* - Reading level is failing:
* - No simplified summary → 'warning'
* - Summary exists and grade is valid (> 0 and not failed) → 'check'
* - Summary grade failed → 'warning'
* - Default → 'warning'
*
* @since 1.38.0
*
* @param bool $has_content Whether the post has enough content to calculate a grade.
* @param int $post_grade Flesch-Kincaid grade level of the post content. 0 means not calculable.
* @param bool $post_grade_failed True when the post grade is above 9th grade (reading level not 'below').
* @param string $summary_text The simplified summary text, if any.
* @param int $summary_grade Flesch-Kincaid grade level of the simplified summary.
* @param bool $summary_grade_failed True when the simplified summary grade is above 9th grade.
* @return string Icon name: 'check' or 'warning'.
*/
function edac_get_readability_panel_icon( bool $has_content, int $post_grade, bool $post_grade_failed, string $summary_text, int $summary_grade, bool $summary_grade_failed ): string {
if ( ! $has_content || 0 === $post_grade ) {
return 'warning';
}
if ( ! $post_grade_failed ) {
return 'check';
}
// Reading level is above 9th grade – evaluate the simplified summary.
if ( ! $summary_text ) {
return 'warning';
}
if ( $summary_grade > 0 && ! $summary_grade_failed ) {
return 'check';
}
if ( $summary_grade_failed ) {
return 'warning';
}
return 'warning';
}
/**
* Render an icon as an HTML string.
*
* PHP equivalent of the Icon JS component. Returns a wrapping the
* requested SVG with the appropriate BEM classes and ARIA attributes.
*
* Supported icon names: check, warning, error, info.
* Supported types (controls color via CSS): success, warning, error, info.
* When $type is omitted the type is derived automatically from $name.
*
* @since 1.38.0
*
* @param string $name Icon name: 'check', 'warning', 'error', or 'info'. Default 'check'.
* @param string $type Optional. BEM modifier type for color: 'success', 'warning', 'error', 'info'.
* When empty, derived automatically from $name.
* @param bool $aria_hidden Whether to add aria-hidden="true". Automatically set to false when
* $aria_label is provided. Default true.
* @param string $aria_label Accessible label. When provided, aria-hidden is set to false and
* aria-label is added to the wrapper span.
* @param string $css_class Additional CSS classes to append to the wrapper span.
* @return string The rendered HTML, or an empty string for an unknown icon name.
*/
function edac_icon( string $name = 'check', string $type = '', bool $aria_hidden = true, string $aria_label = '', string $css_class = '' ): string {
$svgs = [
'check' => ''
. ' '
. ' '
. ' ',
'warning' => ''
. ' '
. ' ',
'error' => ''
. ' '
. ' ',
'info' => ''
. ' '
. ' ',
'dismissed' => ''
. ' '
. ' ',
];
if ( ! isset( $svgs[ $name ] ) ) {
return '';
}
// Auto-derive type from name when not explicitly provided.
if ( '' === $type ) {
$type_map = [
'check' => 'success',
'warning' => 'warning',
'error' => 'error',
'info' => 'info',
'dismissed' => 'dismissed',
];
$type = $type_map[ $name ] ?? '';
}
// Build CSS class string.
$classes = array_filter( [ 'edac-icon', $type ? 'edac-icon--' . $type : '', $css_class ] );
$class_attr = implode( ' ', $classes );
// Build ARIA attributes.
// When an aria-label is supplied the icon must be announced, so aria-hidden is forced off.
$resolved_aria_hidden = $aria_label ? false : $aria_hidden;
$aria_attrs = 'aria-hidden="' . ( $resolved_aria_hidden ? 'true' : 'false' ) . '"';
if ( $aria_label ) {
$aria_attrs .= ' aria-label="' . esc_attr( $aria_label ) . '"';
}
return ''
. $svgs[ $name ]
. ' ';
}