diff --git a/client/src/includes/a11y-result.ts b/client/src/includes/a11y-result.ts index 3b208f450d46..8d429c245690 100644 --- a/client/src/includes/a11y-result.ts +++ b/client/src/includes/a11y-result.ts @@ -4,6 +4,9 @@ import { NodeResult, Result, RunOptions, + ImpactValue, + Check, + Rule, } from 'axe-core'; const toSelector = (str: string | string[]) => @@ -43,6 +46,7 @@ interface WagtailAxeConfiguration { context: ElementContext; options: RunOptions; messages: Record; + customAltTextRuleConfig: any; } /** @@ -70,6 +74,28 @@ export const getAxeConfiguration = ( return null; }; +/** + * Configuration for the custom Axe rules. Custom 'Image alt text quality' rule is disabled by default. + * https://github.com/dequelabs/axe-core/blob/master/doc/API.md#api-name-axeconfigure + */ +export const customAxeRulesConfig = { + checks: [ + { + id: 'check-image-alt-text', + }, + ] as Check[], + rules: [ + { + id: 'alt-text-quality', + impact: 'serious' as ImpactValue, + selector: 'img[alt]', + tags: ['best-practice'], + any: ['check-image-alt-text'], + enabled: false, + }, + ] as Rule[], +}; + /** * Render A11y results based on template elements. */ diff --git a/client/src/includes/userbar.ts b/client/src/includes/userbar.ts index 7ad2e0048e3b..e9639efdbd53 100644 --- a/client/src/includes/userbar.ts +++ b/client/src/includes/userbar.ts @@ -2,7 +2,11 @@ import axe from 'axe-core'; import A11yDialog from 'a11y-dialog'; import { Application } from '@hotwired/stimulus'; -import { getAxeConfiguration, renderA11yResults } from './a11y-result'; +import { + getAxeConfiguration, + renderA11yResults, + customAxeRulesConfig, +} from './a11y-result'; import { DialogController } from '../controllers/DialogController'; import { TeleportController } from '../controllers/TeleportController'; @@ -311,7 +315,30 @@ export class Userbar extends HTMLElement { if (!this.shadowRoot || !accessibilityTrigger || !config) return; - // Initialise Axe based on the configurable context (whole page body by default) and options ('empty-heading', 'p-as-heading' and 'heading-order' rules by default) + // If enabled in configurations, add a custom 'Image alt text quality' rule + // via Axe API https://github.com/dequelabs/axe-core/blob/master/doc/API.md#api-name-axeconfigure + if (config.customAltTextRuleConfig.enabled) { + const imageFileExtensions = config.customAltTextRuleConfig.patterns; + const imageFileExtensionsRegex = new RegExp( + `\\.(${imageFileExtensions.join('|')})`, + 'i', + ); + + const checkImageAltText = (node: Element) => { + const image = node as HTMLImageElement; + const altText = image.getAttribute('alt') || ''; + const hasBadAltText = imageFileExtensionsRegex.test(altText); + return !hasBadAltText; + }; + + customAxeRulesConfig.rules[0].enabled = true; + customAxeRulesConfig.checks[0].evaluate = checkImageAltText; + } + + // Configure custom rules for Axe if any. None of them are enabled by default. + axe.configure(customAxeRulesConfig); + + // Initialise Axe based on the configurable context (whole page body by default) and options ('button-name', empty-heading', 'empty-table-header', 'frame-title', 'heading-order', 'input-button-name', 'link-name', and 'p-as-heading' rules by default) const results = await axe.run(config.context, config.options); const a11yErrorsNumber = results.violations.reduce( diff --git a/wagtail/admin/userbar.py b/wagtail/admin/userbar.py index e1f64ea24c8d..83dce3ee3b20 100644 --- a/wagtail/admin/userbar.py +++ b/wagtail/admin/userbar.py @@ -57,6 +57,26 @@ class AccessibilityItem(BaseItem): "p-as-heading", ] + #: Configures whether the custom 'Image alt text quality' rule is enabled. + #: For more details, see `Axe documentation `__ + axe_alt_text_rule_enabled = True + + #: A list of bad alt text patterns checked by the 'Image alt text quality' rule. + axe_alt_text_rule_patterns = [ + "apng", + "avif", + "gif", + "jpg", + "jpeg", + "jfif", + "pjpeg", + "pjp", + "png", + "svg", + "tif", + "webp", + ] + #: A dictionary that maps axe-core rule IDs to a dictionary of rule options, #: commonly in the format of ``{"enabled": True/False}``. This can be used in #: conjunction with :attr:`axe_run_only` to enable or disable specific rules. @@ -87,6 +107,9 @@ class AccessibilityItem(BaseItem): "Link text is empty. Use meaningful text for screen reader users." ), "p-as-heading": _("Misusing paragraphs as headings. Use proper heading tags."), + "alt-text-quality": _( + "Image alt text has inappropriate pattern. Use meaningful text." + ), } def get_axe_include(self, request): @@ -105,6 +128,26 @@ def get_axe_rules(self, request): """Returns a dictionary that maps axe-core rule IDs to a dictionary of rule options.""" return self.axe_rules + def get_axe_custom_alt_text_rule_enabled(self, request): + """Returns if the custom 'Image alt text quality' rule is enabled.""" + return self.axe_alt_text_rule_enabled + + def get_axe_custom_alt_text_rule_patterns(self, request): + """Returns bad image alt text patterns for the custom 'Image alt text quality' rule.""" + return self.axe_alt_text_rule_patterns + + def get_axe_custom_alt_text_rule(self, request): + """Returns the custom 'Image alt text quality' rule configurations for + `axe.configure `_.""" + custom_alt_text_rule = { + "enabled": self.get_axe_custom_alt_text_rule_enabled(request), + "patterns": self.get_axe_custom_alt_text_rule_patterns(request), + } + # If no patterns are provided, disable the rule + if not custom_alt_text_rule["patterns"]: + custom_alt_text_rule["enabled"] = False + return custom_alt_text_rule + def get_axe_messages(self, request): """Returns a dictionary that maps axe-core rule IDs to custom translatable strings.""" return self.axe_messages @@ -144,6 +187,7 @@ def get_axe_configuration(self, request): "context": self.get_axe_context(request), "options": self.get_axe_options(request), "messages": self.get_axe_messages(request), + "customAltTextRuleConfig": self.get_axe_custom_alt_text_rule(request), } def get_context_data(self, request):