*/ private const BADGE_LETTERS = array( 'critical' => 'C', 'serious' => 'S', 'moderate' => 'M', 'minor' => 'Mi', ); /** * Constructor - registers hooks for every public post type. */ public function __construct() { add_action( 'admin_init', array( $this, 'prepare_rebuild' ), 5 ); add_action( 'admin_init', array( $this, 'register_columns' ) ); add_action( 'pre_get_posts', array( $this, 'handle_sort' ) ); add_filter( 'posts_clauses', array( $this, 'apply_sort_clauses' ), 20, 2 ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_styles' ) ); add_action( 'admin_notices', array( $this, 'render_rebuild_notice' ) ); add_action( 'wp_ajax_acceguru_rebuild_issue_sort', array( $this, 'ajax_rebuild' ) ); } /** * Loads the small column stylesheet on edit.php screens only. * * @param string $hook_suffix Current admin page hook. */ public function enqueue_styles( string $hook_suffix ): void { if ( 'edit.php' === $hook_suffix ) { wp_enqueue_style( 'acceguru-admin-columns', ACCEGURU_URL . 'admin/css/acceguru-admin-columns.css', array(), ACCEGURU_VERSION ); // The cell tooltip needs a script of its own: showing it on focus // and closing it with Escape cannot be done with CSS alone. wp_enqueue_script( 'acceguru-column-tooltip', ACCEGURU_URL . 'admin/js/acceguru-column-tooltip.js', array(), ACCEGURU_VERSION, true ); } if ( ! self::is_rebuild_screen() || ! current_user_can( 'manage_options' ) || ! self::rebuild_state() ) { return; } wp_enqueue_script( 'acceguru-issue-sort-rebuild', ACCEGURU_URL . 'admin/js/acceguru-issue-sort-rebuild.js', array(), ACCEGURU_VERSION, true ); wp_localize_script( 'acceguru-issue-sort-rebuild', 'acceguruIssueSortRebuild', array( 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'acceguru_issue_sort_rebuild' ), // A rebuild that keeps moving says nothing at all; only a stall // or a failed request has a message to show. 'stalledText' => __( 'AcceGuru sorting recalculation is not moving. Keep this admin page open or reload it to retry.', 'acceguru' ), 'errorText' => __( 'AcceGuru sorting recalculation paused. Reload this page to retry.', 'acceguru' ), 'reloadDelay' => 350, ) ); } /** * Hooks the column callbacks onto every public post type list screen. */ public function register_columns(): void { $types = get_post_types( array( 'public' => true ), 'names' ); foreach ( $types as $type ) { add_filter( "manage_{$type}_posts_columns", array( $this, 'add_column' ) ); add_action( "manage_{$type}_posts_custom_column", array( $this, 'render_column' ), 10, 2 ); add_filter( "manage_edit-{$type}_sortable_columns", array( $this, 'make_sortable' ) ); } } /** * Inserts the "A11y" column right before the Date column when present. * * @param array $columns Existing columns. * @return array */ public function add_column( array $columns ): array { $new = array(); foreach ( $columns as $key => $label ) { if ( 'date' === $key ) { $new['acceguru_issues'] = $this->column_label(); } $new[ $key ] = $label; } if ( ! isset( $new['acceguru_issues'] ) ) { $new['acceguru_issues'] = $this->column_label(); } return $new; } /** * Returns the column heading. * * A rebuild after an update is routine housekeeping, so it stays out of the * heading: the column keeps its plain name and only loses the sorting link * until the values are ready. */ private function column_label(): string { // The product name, not a translated word. return 'AcceGuru'; } /** * Renders the column cell for a single post. * * @param string $column Column key. * @param int $post_id Post ID. */ public function render_column( string $column, int $post_id ): void { if ( 'acceguru_issues' !== $column ) { return; } $summary = get_post_meta( $post_id, self::META_KEY, true ); if ( ! is_array( $summary ) ) { $this->render_mark( '-', __( 'Not scanned yet', 'acceguru' ) ); return; } if ( ! Acceguru_Scanner::summary_is_current( $summary ) ) { $this->render_mark( '↻', __( 'Scan settings or rules have changed. Run a new full-site scan.', 'acceguru' ) ); return; } $counts = self::visible_issue_counts( $post_id, $summary ); $by_impact = $counts['by_impact']; $manual = Acceguru_Scanner::manual_check_count( $post_id, $summary ); $badges = array(); $phrases = array(); foreach ( self::BADGE_LETTERS as $level => $letter ) { $count = isset( $by_impact[ $level ] ) ? (int) $by_impact[ $level ] : 0; if ( $count < 1 ) { continue; } $phrase = Acceguru_Admin::impact_label( $level ) . ': ' . $count; $hint = Acceguru_Admin::impact_hint( $level ); $example = Acceguru_Admin::impact_example( $level ); $badges[] = $this->badge( $level, $letter, (string) $count, $phrase, $hint, $example ); $phrases[] = $this->full_phrase( $phrase, $hint, $example ); } // Manual checks keep their own colour and their own wording: nothing // here is a confirmed problem, so they never join the severity ramp. A // record saved before they existed shows a dash rather than a zero, // because "none left" and "never recorded" are different answers. if ( null === $manual ) { $phrase = __( 'Manual Checks: no data', 'acceguru' ); $badges[] = $this->badge( 'manual-empty', 'MC', '-', $phrase ); $phrases[] = $phrase; } elseif ( $manual > 0 ) { /* translators: %d: how many checks a person still has to judge. */ $phrase = sprintf( __( 'Manual Checks: %d', 'acceguru' ), $manual ); $hint = Acceguru_Admin::impact_hint( 'manual' ); $example = Acceguru_Admin::impact_example( 'manual' ); $badges[] = $this->badge( 'manual', 'MC', (string) $manual, $phrase, $hint, $example ); $phrases[] = $this->full_phrase( $phrase, $hint, $example ); } if ( empty( $badges ) ) { $phrase = __( 'No automated violations found in last scan', 'acceguru' ); $badges[] = $this->badge( 'ok', '✓', '', $phrase ); $phrases[] = $phrase; } $view_url = (string) get_permalink( $post_id ); $href = '' !== $view_url ? add_query_arg( 'acceguru', 'open', $view_url ) : ''; $tip_id = 'acceguru-col-tip-' . $post_id; $group = implode( '', $badges ); // One link and one tab stop for the whole row of badges. The badges // themselves are hidden from screen readers: the link carries the name // and the tooltip carries the numbers, so nothing is read twice. if ( '' !== $href ) { $html = sprintf( '%4$s', esc_url( $href ), esc_attr__( 'Accessibility results', 'acceguru' ), esc_attr( $tip_id ), $group ); } else { $html = sprintf( '%3$s', esc_attr__( 'Accessibility results', 'acceguru' ), esc_attr( $tip_id ), $group ); } $html .= sprintf( '', esc_attr( $tip_id ), esc_html( implode( ', ', $phrases ) ) ); echo wp_kses( $html, self::cell_allowed_html() ); } /** * The three lines of a badge as one sentence, for the cell's own tooltip. * The keyboard and a screen reader must be told exactly what the pointer * is told, so the same words go to both. * * @param string $phrase Label and count. * @param string $hint What the level means. * @param string $example The example that follows it. */ private function full_phrase( string $phrase, string $hint, string $example ): string { $text = $phrase; if ( '' !== $hint ) { $text .= ' - ' . $hint; } if ( '' !== $example ) { $text .= ' ' . $example; } return $text; } /** * One badge: the category letter above its number. * * @param string $kind Badge style key. * @param string $letter Short label shown on the first line. * @param string $count Number on the second line, or an empty string for none. * @param string $tip Label and count the pointer shows for this badge. * @param string $hint What the level means, in a few words. * @param string $example The example that follows it, on its own line. * @return string Markup. */ private function badge( string $kind, string $letter, string $count, string $tip, string $hint = '', string $example = '' ): string { $inner = sprintf( '%s', esc_html( $letter ) ); if ( '' !== $count ) { $inner .= sprintf( '%s', esc_html( $count ) ); } // Each badge carries its own wording, so pointing at one explains that // one. The cell's own tooltip still holds the whole list for the // keyboard and for screen readers. Label and explanation travel apart // so the bubble can set the label off from the rest. return sprintf( '', esc_attr( $kind ), esc_attr( $tip ), esc_attr( $hint ), esc_attr( $example ), $inner ); } /** * The two states that have no numbers to show. They are marks, not links, * so the wording travels as the accessible name instead of a title, which * the keyboard cannot reach and screen readers treat inconsistently. * * @param string $mark Character to show. * @param string $label Wording for assistive technology. */ private function render_mark( string $mark, string $label ): void { printf( '%2$s', esc_attr( $label ), esc_html( $mark ) ); } /** * The markup the cell is allowed to print. * * @return array> */ private static function cell_allowed_html(): array { $attributes = array( 'class' => true, 'id' => true, 'role' => true, 'hidden' => true, 'tabindex' => true, 'data-a11y-tip' => true, 'data-a11y-hint' => true, 'data-a11y-example' => true, 'aria-label' => true, 'aria-hidden' => true, 'aria-describedby' => true, ); return array( 'a' => $attributes + array( 'href' => true, 'target' => true, 'rel' => true, ), 'span' => $attributes, ); } /** * Counts issues that remain visible after Free and add-on filters. * * Falls back to the saved summary when an older scan has no detailed issue * list, so a page with legacy results is never shown as clean by mistake. * * @param int $post_id Post being rendered. * @param array $summary Saved scan summary. * @return array{total:int,by_impact:array} */ public static function visible_issue_counts( int $post_id, array $summary ): array { $issues = get_post_meta( $post_id, '_acceguru_scan_results', true ); if ( ! is_array( $issues ) || empty( $issues ) ) { return array( 'total' => isset( $summary['total_issues'] ) ? (int) $summary['total_issues'] : 0, 'by_impact' => isset( $summary['by_impact'] ) && is_array( $summary['by_impact'] ) ? $summary['by_impact'] : array(), ); } $issues = Acceguru_Scanner::filter_issues( $issues, $post_id ); $issues = array_values( array_filter( $issues, static fn( array $issue ): bool => '' !== (string) ( $issue['rule_id'] ?? '' ) ) ); $by_impact = array( 'critical' => 0, 'serious' => 0, 'moderate' => 0, 'minor' => 0, ); foreach ( $issues as $issue ) { $impact = (string) ( $issue['impact'] ?? 'minor' ); if ( isset( $by_impact[ $impact ] ) ) { ++$by_impact[ $impact ]; } } return array( 'total' => count( $issues ), 'by_impact' => $by_impact, ); } /** * Marks the column sortable. * * @param array $columns Sortable columns. * @return array */ public function make_sortable( array $columns ): array { if ( self::rebuild_state() ) { unset( $columns['acceguru_issues'] ); return $columns; } $columns['acceguru_issues'] = 'acceguru_issues'; return $columns; } /** * Orders the list table by the numeric issue count when the A11y column * header is clicked. * * @param WP_Query $query Query object. */ public function handle_sort( WP_Query $query ): void { if ( ! is_admin() || ! $query->is_main_query() ) { return; } if ( 'acceguru_issues' !== $query->get( 'orderby' ) ) { return; } if ( self::rebuild_state() ) { $query->set( 'orderby', 'date' ); $query->set( 'order', 'DESC' ); return; } $order = 'DESC' === strtoupper( (string) $query->get( 'order' ) ) ? 'DESC' : 'ASC'; $query->set( 'acceguru_issue_sort_order', $order ); // Give WP_Query a valid native order. apply_sort_clauses() replaces it // after WordPress has assembled the rest of the list-table query. $query->set( 'orderby', 'ID' ); $query->set( 'order', 'ASC' ); } /** * Applies the three sorting groups: current numbers, stale scans, unscanned. * * @param array $clauses SQL clauses built by WordPress. * @param WP_Query $query Current query. * @return array */ public function apply_sort_clauses( array $clauses, WP_Query $query ): array { $order = (string) $query->get( 'acceguru_issue_sort_order' ); if ( ! in_array( $order, array( 'ASC', 'DESC' ), true ) ) { return $clauses; } global $wpdb; $clauses['join'] .= $wpdb->prepare( " LEFT JOIN {$wpdb->postmeta} AS ag_sort_count ON ({$wpdb->posts}.ID = ag_sort_count.post_id AND ag_sort_count.meta_key = %s)", self::VISIBLE_COUNT_KEY ); $clauses['join'] .= $wpdb->prepare( " LEFT JOIN {$wpdb->postmeta} AS ag_sort_profile ON ({$wpdb->posts}.ID = ag_sort_profile.post_id AND ag_sort_profile.meta_key = %s)", self::VISIBLE_PROFILE_KEY ); $clauses['join'] .= $wpdb->prepare( " LEFT JOIN {$wpdb->postmeta} AS ag_sort_context ON ({$wpdb->posts}.ID = ag_sort_context.post_id AND ag_sort_context.meta_key = %s)", self::VISIBLE_CONTEXT_KEY ); $clauses['join'] .= $wpdb->prepare( " LEFT JOIN {$wpdb->postmeta} AS ag_sort_contract ON ({$wpdb->posts}.ID = ag_sort_contract.post_id AND ag_sort_contract.meta_key = %s)", self::VISIBLE_CONTRACT_KEY ); $clauses['join'] .= $wpdb->prepare( " LEFT JOIN {$wpdb->postmeta} AS ag_sort_record ON ({$wpdb->posts}.ID = ag_sort_record.post_id AND ag_sort_record.meta_key = %s)", self::VISIBLE_RECORD_KEY ); $numeric = $wpdb->prepare( '(ag_sort_record.meta_value = %s AND ag_sort_profile.meta_value = %s AND ag_sort_context.meta_value = %s AND ag_sort_contract.meta_value = %s AND ag_sort_count.meta_value IS NOT NULL)', 'current', Acceguru_Scanner::scan_profile_compatibility_hash(), self::sort_context_hash(), self::scan_contract_key() ); $group_expression = "CASE WHEN {$numeric} THEN 0 WHEN ag_sort_record.meta_value IN ('current', 'stale') THEN 1 ELSE 2 END"; $value_expression = "CASE WHEN {$numeric} THEN CAST(ag_sort_count.meta_value AS SIGNED) END"; if ( '' === (string) $query->get( 'fields' ) ) { // Keep the expressions in SELECT so DISTINCT added by multilingual or // search plugins can still order by them on strict MySQL servers. $clauses['fields'] .= ", {$group_expression} AS acceguru_sort_group, {$value_expression} AS acceguru_sort_value"; $clauses['orderby'] = "acceguru_sort_group ASC, acceguru_sort_value {$order}, {$wpdb->posts}.ID ASC"; } else { $clauses['orderby'] = "{$group_expression} ASC, {$value_expression} {$order}, {$wpdb->posts}.ID ASC"; } return $clauses; } /** * Recalculates the stored sorting value for one post. * * Called after a scan save, during an Ignore/Restore rebuild, and by add-ons * after a per-post filter changes. */ public static function refresh_sort_value( int $post_id ): void { $summary = get_post_meta( $post_id, self::META_KEY, true ); if ( ! is_array( $summary ) ) { delete_post_meta( $post_id, self::VISIBLE_COUNT_KEY ); delete_post_meta( $post_id, self::VISIBLE_PROFILE_KEY ); delete_post_meta( $post_id, self::VISIBLE_CONTEXT_KEY ); delete_post_meta( $post_id, self::VISIBLE_CONTRACT_KEY ); delete_post_meta( $post_id, self::VISIBLE_RECORD_KEY ); return; } $counts = self::visible_issue_counts( $post_id, $summary ); $saved_profile = isset( $summary['scan_profile'] ) && is_array( $summary['scan_profile'] ) ? $summary['scan_profile'] : array(); update_post_meta( $post_id, self::VISIBLE_COUNT_KEY, (int) $counts['total'] ); update_post_meta( $post_id, self::VISIBLE_PROFILE_KEY, Acceguru_Scanner::scan_profile_compatibility_hash( $saved_profile ) ); update_post_meta( $post_id, self::VISIBLE_CONTEXT_KEY, self::sort_context_hash() ); update_post_meta( $post_id, self::VISIBLE_CONTRACT_KEY, self::scan_contract_key( $summary ) ); update_post_meta( $post_id, self::VISIBLE_RECORD_KEY, Acceguru_Scanner::summary_is_current( $summary ) ? 'current' : 'stale' ); } /** * Returns the schema and ruleset identity for a saved or current scan. * * @param array|null $summary Saved scan summary, or null for today's contract. */ private static function scan_contract_key( ?array $summary = null ): string { $schema = null === $summary ? Acceguru_Scanner::SCAN_SCHEMA_VERSION : (int) ( $summary['schema_version'] ?? 0 ); $ruleset = null === $summary ? Acceguru_Scanner::RULESET_VERSION : (string) ( $summary['ruleset_version'] ?? '' ); return $schema . ':' . $ruleset; } /** * Returns a stable hash for the add-ons that alter visible issue counts. * * Free owns only the contract. Add-ons append stable scalar/array values; * changing one starts a resumable rebuild without Free naming the add-on. */ public static function sort_context_hash(): string { $ignored = Acceguru_Scanner::get_ignored_rules(); sort( $ignored, SORT_STRING ); $context = array( 'schema' => self::SORT_SCHEMA, 'ignored_rules' => array_values( $ignored ), ); $context = apply_filters( 'acceguru_issue_sort_context', $context ); if ( ! is_array( $context ) ) { $context = array( 'schema' => self::SORT_SCHEMA, 'ignored_rules' => array_values( $ignored ), ); } return hash( 'sha256', (string) wp_json_encode( self::canonicalize_context( $context ) ) ); } /** * Starts or resumes the sorting-cache migration on relevant admin screens. */ public function prepare_rebuild(): void { if ( ! current_user_can( 'edit_posts' ) ) { return; } self::ensure_rebuild_state(); $state = self::rebuild_state(); if ( ! $state ) { return; } global $pagenow; $requested_sort = isset( $_GET['orderby'] ) ? sanitize_key( (string) wp_unslash( $_GET['orderby'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only list sorting. if ( 'edit.php' === $pagenow && 'acceguru_issues' === $requested_sort && ! wp_doing_ajax() ) { $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( (string) wp_unslash( $_SERVER['REQUEST_URI'] ) ) : admin_url( 'edit.php' ); $target = remove_query_arg( array( 'orderby', 'order' ), $request_uri ); $target = add_query_arg( 'acceguru_sort_reset', '1', $target ); wp_safe_redirect( $target ); exit; } if ( current_user_can( 'manage_options' ) && self::is_rebuild_screen() && ! wp_doing_ajax() ) { self::process_rebuild_batch(); } } /** * Shows why sorting is temporarily unavailable and how far the rebuild got. */ public function render_rebuild_notice(): void { if ( ! self::is_rebuild_screen() ) { return; } $state = self::rebuild_state(); $was_reset = isset( $_GET['acceguru_sort_reset'] ) && '1' === sanitize_text_field( (string) wp_unslash( $_GET['acceguru_sort_reset'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only notice flag. if ( ! $state ) { if ( $was_reset ) { ?>

120; if ( $was_reset ) : ?>

>

__( 'You are not allowed to rebuild AcceGuru sorting data.', 'acceguru' ) ), 403 ); } $result = self::process_rebuild_batch(); $state = self::rebuild_state(); wp_send_json_success( array( 'done' => ! $state, 'busy' => ! empty( $result['busy'] ), 'remaining' => $state ? (int) $state['remaining'] : 0, 'stalled' => $state ? ( time() - (int) $state['last_progress'] ) > 120 : false, ) ); } /** * Returns the active rebuild state, or null when sorting is ready. * * @return array|null */ private static function rebuild_state(): ?array { $state = get_option( self::REBUILD_OPTION, null ); if ( ! is_array( $state ) || empty( $state['context'] ) ) { return null; } return array( 'context' => (string) $state['context'], 'last_id' => (int) ( $state['last_id'] ?? 0 ), 'total' => (int) ( $state['total'] ?? 0 ), 'processed' => (int) ( $state['processed'] ?? 0 ), 'remaining' => (int) ( $state['remaining'] ?? 0 ), 'started_at' => (int) ( $state['started_at'] ?? time() ), 'last_progress' => (int) ( $state['last_progress'] ?? time() ), ); } /** * Creates a new resumable rebuild when the schema or add-on context changed. */ private static function ensure_rebuild_state(): void { $current_context = self::sort_context_hash(); $completed = get_option( self::CONTEXT_OPTION, '' ); $completed = is_scalar( $completed ) ? (string) $completed : ''; $schema = (int) get_option( self::SCHEMA_OPTION, 0 ); if ( self::SORT_SCHEMA === $schema && hash_equals( $current_context, $completed ) ) { delete_option( self::REBUILD_OPTION ); return; } $state = self::rebuild_state(); if ( $state && hash_equals( $current_context, (string) $state['context'] ) ) { return; } global $wpdb; $total = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- One count starts a bounded migration. $wpdb->prepare( "SELECT COUNT(DISTINCT post_id) FROM {$wpdb->postmeta} WHERE meta_key = %s", self::META_KEY ) ); $now = time(); update_option( self::REBUILD_OPTION, array( 'context' => $current_context, 'last_id' => 0, 'total' => $total, 'processed' => 0, 'remaining' => $total, 'started_at' => $now, 'last_progress' => $now, ), false ); } /** * Rebuilds one bounded batch, protected by an atomic cross-tab lock. * * @return array{busy:bool} */ private static function process_rebuild_batch(): array { self::ensure_rebuild_state(); $state = self::rebuild_state(); if ( ! $state ) { return array( 'busy' => false ); } $existing_lock = get_option( self::LOCK_OPTION, null ); if ( is_array( $existing_lock ) && ( time() - (int) ( $existing_lock['time'] ?? 0 ) ) > 120 ) { delete_option( self::LOCK_OPTION ); } $token = wp_generate_uuid4(); if ( ! add_option( self::LOCK_OPTION, array( 'token' => $token, 'time' => time() ), '', false ) ) { return array( 'busy' => true ); } try { $state = self::rebuild_state(); if ( ! $state || ! hash_equals( self::sort_context_hash(), (string) $state['context'] ) ) { self::ensure_rebuild_state(); return array( 'busy' => false ); } global $wpdb; $ids = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Resumable primary-key batch. $wpdb->prepare( "SELECT DISTINCT post_id FROM {$wpdb->postmeta} WHERE meta_key = %s AND post_id > %d ORDER BY post_id ASC LIMIT %d", self::META_KEY, (int) $state['last_id'], self::REBUILD_BATCH_SIZE ) ); $ids = array_values( array_filter( array_map( 'absint', is_array( $ids ) ? $ids : array() ) ) ); foreach ( $ids as $post_id ) { self::refresh_sort_value( $post_id ); } if ( empty( $ids ) || count( $ids ) < self::REBUILD_BATCH_SIZE ) { update_option( self::CONTEXT_OPTION, (string) $state['context'], false ); update_option( self::SCHEMA_OPTION, self::SORT_SCHEMA, false ); delete_option( self::REBUILD_OPTION ); return array( 'busy' => false ); } $state['last_id'] = (int) max( $ids ); $state['processed'] = min( (int) $state['total'], (int) $state['processed'] + count( $ids ) ); $state['remaining'] = max( 0, (int) $state['total'] - (int) $state['processed'] ); $state['last_progress'] = time(); update_option( self::REBUILD_OPTION, $state, false ); return array( 'busy' => false ); } finally { $lock = get_option( self::LOCK_OPTION, null ); if ( is_array( $lock ) && hash_equals( $token, (string) ( $lock['token'] ?? '' ) ) ) { delete_option( self::LOCK_OPTION ); } } } /** * Whether this request can visibly advance or report the rebuild. */ private static function is_rebuild_screen(): bool { global $pagenow; if ( 'edit.php' === $pagenow ) { return true; } $page = isset( $_GET['page'] ) ? sanitize_key( (string) wp_unslash( $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only screen detection. return 'acceguru' === $page || str_starts_with( $page, 'acceguru-' ); } /** * Canonicalizes add-on context before hashing it. * * @param mixed $value Context value. * @return mixed */ private static function canonicalize_context( mixed $value ): mixed { if ( ! is_array( $value ) ) { return is_scalar( $value ) || null === $value ? $value : null; } ksort( $value ); foreach ( $value as $key => $item ) { $value[ $key ] = self::canonicalize_context( $item ); } return $value; } }