├── .editorconfig ├── .eslintrc ├── .github ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE │ ├── Bug_report.md │ ├── Feature_request.md │ └── config.yml ├── PULL_REQUEST_TEMPLATE.md ├── SECURITY.md ├── prompts │ └── test-markup.prompt.md └── workflows │ ├── code-coverage-and-coveralls.yml │ ├── cs.yml │ ├── deploy-on-release-to-dot-org.yml │ ├── jest-test-axe-custom-rules.yml │ ├── lint-js.yml │ ├── lint-php.yml │ ├── phpunit.yml │ ├── security.yml │ └── wp-version-checker.yml ├── .gitignore ├── .husky └── pre-commit ├── .phpcs.xml.dist ├── .travis.yml ├── LICENSE.txt ├── README.md ├── accessibility-checker.php ├── admin ├── AdminPage │ ├── FixesPage.php │ ├── FixesSettingType │ │ ├── Checkbox.php │ │ └── Text.php │ └── PageInterface.php ├── class-accessibility-statement.php ├── class-admin-notices.php ├── class-admin.php ├── class-ajax.php ├── class-enqueue-admin.php ├── class-frontend-highlight.php ├── class-helpers.php ├── class-insert-rule-data.php ├── class-issues-query.php ├── class-meta-boxes.php ├── class-post-save.php ├── class-purge-post-data.php ├── class-scans-stats.php ├── class-settings.php ├── class-update-database.php ├── class-welcome-page.php ├── class-widgets.php ├── opt-in │ └── class-email-opt-in.php └── site-health │ ├── class-audit-history.php │ ├── class-free.php │ ├── class-information.php │ └── class-pro.php ├── assets ├── fonts │ ├── anww.eot │ ├── anww.svg │ ├── anww.ttf │ └── anww.woff └── images │ ├── Accessibility Checker logo transparent bg.svg │ ├── contrast icon blue.png │ ├── contrast icon navy.png │ ├── contrast icon red.png │ ├── edac-emblem.png │ ├── edac-logo.png │ ├── highlight-icon-error.svg │ ├── highlight-icon-ignored.svg │ ├── highlight-icon-warning.svg │ ├── ignore icon blue.png │ ├── ignore icon navy.png │ ├── ignore-icon.svg │ ├── readability icon blue.png │ ├── readability icon navy.png │ ├── readability icon white.png │ └── warning icon yellow.png ├── changelog.txt ├── composer.json ├── composer.lock ├── includes ├── activation.php ├── classes │ ├── Fixes │ │ ├── Fix │ │ │ ├── AddFileSizeAndTypeToLinkedFilesFix.php │ │ │ ├── AddLabelToUnlabelledFormFieldsFix.php │ │ │ ├── AddMissingOrEmptyPageTitleFix.php │ │ │ ├── AddNewWindowWarningFix.php │ │ │ ├── BlockPDFUploadsFix.php │ │ │ ├── CommentSearchLabelFix.php │ │ │ ├── FocusOutlineFix.php │ │ │ ├── HTMLLangAndDirFix.php │ │ │ ├── LinkUnderline.php │ │ │ ├── MetaViewportScalableFix.php │ │ │ ├── PreventLinksOpeningNewWindowFix.php │ │ │ ├── ReadMoreAddTitleFix.php │ │ │ ├── RemoveTitleIfPrefferedAccessibleNameFix.php │ │ │ ├── SkipLinkFix.php │ │ │ └── TabindexFix.php │ │ ├── FixInterface.php │ │ └── FixesManager.php │ ├── WPCLI │ │ ├── BootstrapCLI.php │ │ └── Command │ │ │ ├── CLICommandInterface.php │ │ │ ├── DeleteStats.php │ │ │ ├── GetSiteStats.php │ │ │ └── GetStats.php │ ├── class-accessibility-statement.php │ ├── class-enqueue-frontend.php │ ├── class-lazyload-filter.php │ ├── class-plugin.php │ ├── class-rest-api.php │ ├── class-simplified-summary.php │ └── class-summary-generator.php ├── deactivation.php ├── deprecated │ ├── class-issues-query.php │ ├── class-scans-stats.php │ └── deprecated.php ├── helper-functions.php ├── options-page.php └── rules.php ├── languages ├── accessibility-checker-ar.mo ├── accessibility-checker-ar.po ├── accessibility-checker-bg_BG.mo ├── accessibility-checker-bg_BG.po ├── accessibility-checker-cs_CZ.mo ├── accessibility-checker-cs_CZ.po ├── accessibility-checker-da_DK.mo ├── accessibility-checker-da_DK.po ├── accessibility-checker-de_DE.mo ├── accessibility-checker-de_DE.po ├── accessibility-checker-el.mo ├── accessibility-checker-el.po ├── accessibility-checker-es_ES.mo ├── accessibility-checker-es_ES.po ├── accessibility-checker-et.mo ├── accessibility-checker-et.po ├── accessibility-checker-fi.mo ├── accessibility-checker-fi.po ├── accessibility-checker-fr_FR.mo ├── accessibility-checker-fr_FR.po ├── accessibility-checker-he_IL.mo ├── accessibility-checker-he_IL.po ├── accessibility-checker-hu_HU.mo ├── accessibility-checker-hu_HU.po ├── accessibility-checker-id_ID.mo ├── accessibility-checker-id_ID.po ├── accessibility-checker-it_IT.mo ├── accessibility-checker-it_IT.po ├── accessibility-checker-ja.mo ├── accessibility-checker-ja.po ├── accessibility-checker-ko_KR.mo ├── accessibility-checker-ko_KR.po ├── accessibility-checker-lt_LT.mo ├── accessibility-checker-lt_LT.po ├── accessibility-checker-lv.mo ├── accessibility-checker-lv.po ├── accessibility-checker-nb_NO.mo ├── accessibility-checker-nb_NO.po ├── accessibility-checker-nl_NL.mo ├── accessibility-checker-nl_NL.po ├── accessibility-checker-pl_PL.mo ├── accessibility-checker-pl_PL.po ├── accessibility-checker-pt_BR.mo ├── accessibility-checker-pt_BR.po ├── accessibility-checker-pt_PT.mo ├── accessibility-checker-pt_PT.po ├── accessibility-checker-ro_RO.mo ├── accessibility-checker-ro_RO.po ├── accessibility-checker-ru_RU.mo ├── accessibility-checker-ru_RU.po ├── accessibility-checker-sk_SK.mo ├── accessibility-checker-sk_SK.po ├── accessibility-checker-sl_SI.mo ├── accessibility-checker-sl_SI.po ├── accessibility-checker-sv_SE.mo ├── accessibility-checker-sv_SE.po ├── accessibility-checker-th.mo ├── accessibility-checker-th.po ├── accessibility-checker-tr_TR.mo ├── accessibility-checker-tr_TR.po ├── accessibility-checker-uk.mo ├── accessibility-checker-uk.po ├── accessibility-checker-vi.mo ├── accessibility-checker-vi.po ├── accessibility-checker-zh_CN.mo ├── accessibility-checker-zh_CN.po └── accessibility-checker.pot ├── package-lock.json ├── package.json ├── partials ├── admin-page │ └── fixes-page.php ├── custom-meta-box.php ├── pro-callout.php ├── settings-page.php └── welcome-page.php ├── patches └── @wordpress+env+8.8.0.patch ├── phpcs.xml ├── phpstan.neon ├── phpunit.dev.xml ├── phpunit.xml.dist ├── readme.txt ├── run-composer.sh ├── scripts ├── dist.sh ├── hard-reset.sh ├── prep_release.sh └── prepare.sh ├── src ├── admin │ ├── fixes-page │ │ ├── conditional-disable-settings.js │ │ ├── conditional-required-settings.js │ │ └── pro-callout.js │ ├── images │ │ ├── checkmark icon green.png │ │ ├── contrast icon white.png │ │ ├── error icon red.png │ │ ├── error icon white.png │ │ ├── ignore icon white.png │ │ ├── list-check.png │ │ ├── warning icon navy.png │ │ ├── warning icon white.png │ │ ├── welcome-screenshot-medium.png │ │ ├── welcome-screenshot-small.png │ │ └── welcome-screenshot-standard.png │ ├── index.js │ ├── sass │ │ └── accessibility-checker-admin.scss │ └── summary │ │ └── summary-tab-input-event-handlers.js ├── common │ ├── helpers.js │ ├── sass │ │ ├── _fix-settings.scss │ │ ├── _helpers.scss │ │ └── _variables.scss │ └── saveFixSettingsRest.js ├── editorApp │ ├── checkPage.js │ ├── helpers.js │ ├── index.js │ └── settings.js ├── emailOptIn │ ├── index.js │ ├── modal.js │ └── sass │ │ └── email-opt-in.scss ├── frontendFixes │ ├── Fixes │ │ ├── langAndDirFix.js │ │ ├── metaViewportScalableFix.js │ │ ├── newWindowWarning.js │ │ ├── preventLinksOpeningNewWindowFix.js │ │ ├── removeTitleIfPrefferedAccessibleNameFix.js │ │ ├── skipLinkFix.js │ │ ├── tabindexFix.js │ │ └── underlineFix.js │ └── index.js ├── frontendHighlighterApp │ ├── fixesModal.js │ ├── images │ │ ├── edac-emblem.png │ │ ├── highlight-icon-error.svg │ │ ├── highlight-icon-ignored.svg │ │ ├── highlight-icon-warning.svg │ │ └── ignore-icon.svg │ ├── index.js │ └── sass │ │ └── app.scss └── pageScanner │ ├── checks │ ├── always-fail.js │ ├── anchor-exists.js │ ├── aria-describedby-not-found.js │ ├── aria-hidden-valid-usage.js │ ├── aria-label-not-found.js │ ├── aria-owns-not-found.js │ ├── button-is-empty.js │ ├── element-is-u-tag.js │ ├── element-with-underline.js │ ├── has-ambiguous-text.js │ ├── has-subheadings-if-long-content.js │ ├── has-text-docoration.js │ ├── has-transcript.js │ ├── heading-is-empty.js │ ├── image-input-has-alt.js │ ├── img-alt-empty-check.js │ ├── img-alt-invalid-check.js │ ├── img-alt-long-check.js │ ├── img-alt-missing-check.js │ ├── img-alt-redundant-check.js │ ├── img-animated-check.js │ ├── is-video-detected.js │ ├── link-has-valid-href-or-role.js │ ├── link-is-empty.js │ ├── link-points-to-html.js │ ├── link-target-blank-without-informing.js │ ├── linked-image-alt-not-empty.js │ ├── linked-image-alt-present.js │ ├── longdesc-valid.js │ ├── paragraph-not-empty.js │ ├── paragraph-styled-as-header.js │ ├── slider-detected.js │ ├── table-has-headers.js │ ├── table-header-is-empty.js │ ├── text-is-justified.js │ └── text-size-too-small.js │ ├── config │ ├── exclusions.js │ └── rules.js │ ├── helpers │ ├── density.js │ ├── helpers.js │ └── linkedImageUtils.js │ ├── index.js │ ├── rules │ ├── aria-broken-reference.js │ ├── aria-hidden-validation.js │ ├── broken-anchor-link.js │ ├── color-contrast-failure.js │ ├── custom-rule-1.js │ ├── empty-button.js │ ├── empty-heading-tag.js │ ├── empty-link.js │ ├── empty-paragraph.js │ ├── empty-table-header.js │ ├── extended │ │ └── label.js │ ├── img-alt-empty.js │ ├── img-alt-invalid.js │ ├── img-alt-long.js │ ├── img-alt-missing.js │ ├── img-alt-redundant.js │ ├── img-animated.js │ ├── img-linked-alt-empty.js │ ├── img-linked-alt-missing.js │ ├── link-ambiguous-text.js │ ├── link-improper.js │ ├── link-ms-office-file.js │ ├── link-non-html-file.js │ ├── link-pdf.js │ ├── link_target_blank.js │ ├── long-description-invalid.js │ ├── missing-headings.js │ ├── missing-transcript.js │ ├── possible-heading.js │ ├── slider-present.js │ ├── table-header-missing.js │ ├── text-justified.js │ ├── text-small.js │ ├── underlined-text.js │ └── video-present.js │ └── utils │ └── aria-utils.js ├── tests ├── assets │ ├── animated.gif │ ├── animated.webp │ ├── static.gif │ └── static.webp ├── bin │ └── install-wp-tests.sh ├── bootstrap-dev.php ├── bootstrap.php ├── jest │ ├── babel.config.js │ ├── helpers │ │ └── NormalizedMap.test.js │ ├── jest.config.js │ ├── mock-assets │ │ ├── A-image.gif │ │ ├── A-image.webp │ │ ├── S-1x1.gif │ │ ├── S-animated-in-name.gif │ │ ├── S-animated-in-name.webp │ │ ├── S-image.gif │ │ ├── S-image.jpg │ │ └── S-image.webp │ ├── pageScanner │ │ └── context.test.js │ └── rules │ │ ├── ariaHiddenValid.test.js │ │ ├── brokenAriaReference.test.js │ │ ├── duplicateFormLabel.test.js │ │ ├── emptyButton.test.js │ │ ├── emptyHeading.test.js │ │ ├── emptyLink.test.js │ │ ├── emptyTableHeader.test.js │ │ ├── iframeMissingTitle.test.js │ │ ├── imageMapMissingAltText.test.js │ │ ├── imgAltEmpty.test.js │ │ ├── imgAltInvalid.test.js │ │ ├── imgAltLong.test.js │ │ ├── imgAltMissing.test.js │ │ ├── imgAltRedundant.test.js │ │ ├── imgAnimatedGif.test.js │ │ ├── imgLinkedAltEmpty.test.js │ │ ├── imgLinkedAltMissing.test.js │ │ ├── linkImproper.test.js │ │ ├── linkNonHTMLFile.test.js │ │ ├── longDescriptionInvalid.test.js │ │ ├── missingHeadings.test.js │ │ ├── missingTranscript.test.js │ │ ├── sliderPresent.test.js │ │ ├── tableMissingHeader.test.js │ │ └── videoElementPresent.test.js └── phpunit │ ├── Admin │ ├── AdminNoticesTest.php │ ├── EnqueueAdminTest.php │ ├── HelpersTest.php │ ├── InsertRuleDataTest.php │ ├── MetaBoxesTest.php │ ├── OptIn │ │ └── EmailOptInTest.php │ └── PurgePostDataTest.php │ ├── RegisterRulesTest.php │ ├── TestHelpers │ ├── DatabaseHelpers.php │ └── Mocks │ │ └── Mock_WP_CLI.php │ ├── helper-functions │ ├── CompareStringsTest.php │ └── OrdinalTest.php │ └── includes │ └── classes │ ├── AccessibilityStatementTest.php │ ├── Fixes │ └── FixesManagerTest.php │ ├── SimplifiedSummaryTest.php │ ├── SummaryGeneratorTest.php │ └── WPCLI │ ├── BootstrapCLITest.php │ └── Commands │ ├── DeleteStatsTest.php │ ├── GetSiteStatsTest.php │ └── GetStatsTest.php ├── uninstall.php ├── update-composer-config.php └── webpack.config.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # This file is for unifying the coding style for different editors and IDEs 2 | # editorconfig.org 3 | 4 | # WordPress Coding Standards 5 | # https://make.wordpress.org/core/handbook/coding-standards/ 6 | 7 | root = true 8 | 9 | [*] 10 | charset = utf-8 11 | end_of_line = lf 12 | insert_final_newline = true 13 | trim_trailing_whitespace = true 14 | indent_style = tab 15 | 16 | [*.{yml,yaml}] 17 | indent_style = space 18 | indent_size = 2 19 | 20 | [*.{gradle,java,kt}] 21 | indent_style = space 22 | 23 | [packages/react-native-*/**.xml] 24 | indent_style = space 25 | 26 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ "plugin:@wordpress/eslint-plugin/esnext" ], 3 | "env": { 4 | "browser": true, 5 | "node": false, 6 | "jest": true, 7 | }, 8 | "globals": { 9 | "wp": true, 10 | "jQuery": true, 11 | "edac_script_vars": true, 12 | "ajaxurl": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | 10 | 11 | * [ ] I've read and understood the [contribution guidelines](https://github.com/equalizedigital/accessibility-checker/blob/develop/.github/CONTRIBUTING.md). 12 | * [ ] I've searched for any related issues and avoided creating a duplicate issue. 13 | 14 | ### Please give us a description of what happened. 15 | 16 | 17 | 18 | 19 | ### Please describe what you expected to happen and why. 20 | 21 | 22 | 23 | 24 | ### How can we reproduce this behavior? 25 | 1. 26 | 2. 27 | 3. 28 | 29 | ### Technical info 30 | * WordPress version: 31 | * Accessibility Checker version: 32 | 33 | * If relevant, which editor is affected (or editors): 34 | - [ ] Classic Editor 35 | - [ ] Gutenberg 36 | - [ ] Classic Editor plugin 37 | - [ ] Page Builder (please specify): 38 | 39 | 40 | * Which browser is affected (or browsers): 41 | - [ ] Internet Explorer 42 | - [ ] Edge 43 | - [ ] Chrome 44 | - [ ] Firefox 45 | - [ ] Safari 46 | - [ ] Brave 47 | 48 | * Relevant plugins in case of a bug: 49 | 50 | * Tested with theme: 51 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | 8 | 9 | ## Is your feature request related to a problem? Please describe. 10 | 11 | ## Describe the solution you'd like 12 | 13 | ## Why do you think this feature is something we should consider for this plugin? 14 | 15 | ## Additional context 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Checklist 4 | 5 | - [ ] PR is linked to the main issue in the repo 6 | - [ ] Tests are added that cover changes 7 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | You can report any security bugs found in the source code of this plugin through the [Patchstack Vulnerability Disclosure Program](https://patchstack.com/database/vdp/accessibility-checker). The Patchstack team will assist you with verification, CVE assignment and take care of notifying the developers of this plugin. 6 | 7 | ## Responding to Vulnerability Reports 8 | 9 | Equalize Digital takes security bugs seriously. We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions. Patchstack will work with you and us to deal with the security issue as best as possible. 10 | 11 | ## Disclosing a Vulnerability 12 | 13 | Once an issue is reported, Equalize Digital uses the following disclosure process: 14 | 15 | - When a report is received, we confirm the issue and determine its severity together with Patchstack. 16 | - If we know of specific third-party services or software that require mitigation before publication, those projects will be notified. 17 | - An advisory is prepared (but not published) which details the problem and steps for mitigation. 18 | - Patch releases are published and the advisory is published. 19 | - Release notes and our `changelog.txt` will include a `Security` section with a link to the advisory. 20 | 21 | We credit reporters for identifying vulnerabilities, although we will keep your name confidential if you request it. 22 | -------------------------------------------------------------------------------- /.github/prompts/test-markup.prompt.md: -------------------------------------------------------------------------------- 1 | I have a set of accessibility test cases. Can you generate WordPress block editor markup that I can paste into the editor which matches all these test cases? 2 | 3 | Please follow these guidelines: 4 | 5 | 1. **Separate the test cases into two sections**: 6 | - One for tests expected to **pass** 7 | - One for tests expected to **fail** 8 | 9 | 2. **Use proper HTML hierarchy**: 10 | - Add an `

` heading titled "Passing Tests" above the passing block markup. 11 | - Add an `

` heading titled "Failing Tests" above the failing block markup. 12 | - Each test case should have its name included as an `

` heading above it. 13 | 14 | 3. **Wrap the test markup inside a Gutenberg HTML block**: 15 | - Enclose each example in `` and `` so it’s valid in Code Editor mode. 16 | - Do **not** modify the test markup—output it exactly as provided, except add the required `data-test-name` attribute for failing cases as specified in step 4. 17 | 18 | 4. **Add a unique `data-test-name` attribute**: 19 | - For any element in the failing test case that will be flagged in a scan, add a `data-test-name="..."` attribute, using the test name or a simplified identifier. 20 | - This helps ensure the issue is clearly identifiable. 21 | 22 | 5. **Output only valid WordPress block markup**: 23 | - Structure it so I can copy and paste it directly into the block editor in "Code Editor" mode. 24 | 25 | Optional: 26 | - If possible, at the end of the output, summarize how many failing cases there are. -------------------------------------------------------------------------------- /.github/workflows/cs.yml: -------------------------------------------------------------------------------- 1 | name: CS 2 | 3 | on: 4 | # Run on all pushes (except to main) and on all pull requests. 5 | push: 6 | branches: 7 | - '*' 8 | pull_request: 9 | branches: 10 | - '*' 11 | # Allow manually triggering the workflow. 12 | workflow_dispatch: 13 | 14 | # Cancels all previous workflow runs for the same branch that have not yet completed. 15 | concurrency: 16 | # The concurrency group contains the workflow name and the branch name. 17 | group: ${{ github.workflow }}-${{ github.ref }} 18 | cancel-in-progress: true 19 | 20 | jobs: 21 | checkcs: 22 | name: 'Check code style' 23 | runs-on: ubuntu-latest 24 | 25 | steps: 26 | - name: Checkout code 27 | uses: actions/checkout@v3 28 | 29 | - name: Run custom composer script 30 | run: ./run-composer.sh 31 | 32 | - name: Install PHP 33 | uses: shivammathur/setup-php@v2 34 | with: 35 | php-version: '7.4' 36 | coverage: none 37 | tools: cs2pr 38 | 39 | # Validate the composer.json file. 40 | # @link https://getcomposer.org/doc/03-cli.md#validate 41 | - name: Validate Composer installation 42 | run: composer validate --no-check-all 43 | 44 | # Install dependencies and handle caching in one go. 45 | # @link https://github.com/marketplace/actions/install-composer-dependencies 46 | - name: Install Composer dependencies 47 | uses: ramsey/composer-install@v2 48 | with: 49 | # Bust the cache at least once a month - output format: YYYY-MM-DD. 50 | custom-cache-suffix: $(date -u -d "-0 month -$(($(date +%d)-1)) days" "+%F") 51 | 52 | # Check the codestyle of the files. 53 | # The results of the CS check will be shown inline in the PR via the CS2PR tool. 54 | # @link https://github.com/staabm/annotate-pull-request-from-checkstyle/ 55 | - name: Check PHP code style 56 | id: phpcs 57 | run: composer check-cs -- --report-full --report-checkstyle=./phpcs-report.xml --ignore=vendor 58 | 59 | - name: Show PHPCS results in PR 60 | if: ${{ always() && steps.phpcs.outcome == 'failure' }} 61 | run: cs2pr ./phpcs-report.xml -------------------------------------------------------------------------------- /.github/workflows/deploy-on-release-to-dot-org.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to WordPress.org Repository 2 | 3 | # Allow manual triggering for testing but also trigger on release for real run 4 | on: 5 | workflow_dispatch: 6 | release: 7 | types: [released] 8 | 9 | jobs: 10 | # This job is based on and relies on the 10up action-wordpress-plugin-deploy action 11 | deploy_to_wp_repository: 12 | name: Deploy to WP.org 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v4 17 | 18 | - name: Setup Composer 19 | uses: shivammathur/setup-php@v2 20 | with: 21 | php-version: '7.4' 22 | tools: composer:v2 23 | coverage: none 24 | 25 | # Some images won't have svn available. Install it if that's the case. 26 | - name: Install SVN 27 | run: | 28 | if ! command -v svn &> /dev/null; then 29 | echo "Installing SVN..." 30 | sudo apt-get update --allow-releaseinfo-change || { echo "Failed to update package lists"; exit 1; } 31 | sudo apt-get install -y subversion || { echo "Failed to install SVN"; exit 1; } 32 | else 33 | echo "SVN is already installed" 34 | fi 35 | 36 | - name: Setup Node.js 37 | uses: actions/setup-node@v4 38 | with: 39 | node-version: 20 40 | cache: 'npm' 41 | 42 | - name: NPM install, build and generate release artefacts 43 | id: dist-build 44 | run: | 45 | npm install --ignore-scripts 46 | npm run build 47 | npm run dist:dotorg 48 | echo "::set-output name=zip-path::./dist/${{ github.event.repository.name }}/${{ github.event.repository.name }}.zip" 49 | 50 | - name: WordPress plugin deploy 51 | id: deploy 52 | uses: 10up/action-wordpress-plugin-deploy@stable 53 | env: 54 | SVN_USERNAME: ${{ secrets.SVN_USERNAME }} 55 | SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }} 56 | BUILD_DIR: ./dist/${{ github.event.repository.name }}/ 57 | -------------------------------------------------------------------------------- /.github/workflows/jest-test-axe-custom-rules.yml: -------------------------------------------------------------------------------- 1 | name: Run Jest on Rule Changes 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - 'src/pageScanner/rules/**/*' 7 | - 'src/pageScanner/checks/**/*' 8 | 9 | jobs: 10 | jest: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v4 15 | 16 | - name: Set up Node.js 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: '22' # Or your preferred Node.js version 20 | 21 | - name: Cache dependencies 22 | uses: actions/cache@v4 23 | with: 24 | path: ~/.npm 25 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 26 | restore-keys: | 27 | ${{ runner.os }}-node- 28 | 29 | - name: Install dependencies (skipping postinstall) 30 | run: | 31 | npm config set ignore-scripts true 32 | npm install 33 | 34 | - name: Run Jest tests 35 | run: npm run test:jest 36 | -------------------------------------------------------------------------------- /.github/workflows/lint-js.yml: -------------------------------------------------------------------------------- 1 | name: Lint JS 2 | 3 | on: 4 | # Run on pushes to select branches and on all pull requests. 5 | push: 6 | branches: 7 | - main 8 | - develop 9 | - trunk 10 | - 'feature/**' 11 | - 'release/**' 12 | - 'hotfix/[0-9]+.[0-9]+*' 13 | pull_request: 14 | # Allow manually triggering the workflow. 15 | workflow_dispatch: 16 | 17 | # Cancels all previous workflow runs for the same branch that have not yet completed. 18 | concurrency: 19 | # The concurrency group contains the workflow name and the branch name. 20 | group: ${{ github.workflow }}-${{ github.ref }} 21 | cancel-in-progress: true 22 | 23 | jobs: 24 | lint: 25 | runs-on: ubuntu-latest 26 | 27 | name: "Lint: JS" 28 | 29 | steps: 30 | - name: Checkout code 31 | uses: actions/checkout@v3 32 | 33 | - name: Cache Node.js modules 34 | uses: actions/cache@v4 35 | with: 36 | path: node_modules 37 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 38 | restore-keys: | 39 | ${{ runner.os }}-node- 40 | # The lint stage doesn't run the unit tests or use code style, so no need for PHPUnit, WPCS or phpcompatibility. 41 | - name: 'Install NPM packages' 42 | run: npm install --ignore-scripts 43 | 44 | - name: Get only files changed in this PR 45 | id: changed-files 46 | uses: actions/github-script@v6 47 | with: 48 | script: | 49 | if (!context.payload.pull_request) { 50 | core.warning("No pull request context available. Skipping changed files retrieval."); 51 | core.setOutput('files', ''); 52 | return ''; 53 | } 54 | 55 | const changedFiles = await github.paginate( 56 | github.rest.pulls.listFiles, 57 | { 58 | owner: context.repo.owner, 59 | repo: context.repo.repo, 60 | pull_number: context.payload.pull_request.number, 61 | } 62 | ); 63 | const jsFiles = changedFiles 64 | .filter(file => file.status !== 'removed') 65 | .map(file => file.filename) 66 | .filter(filename => filename.endsWith('.js')) 67 | .join(' '); 68 | 69 | core.setOutput('files', jsFiles); 70 | return jsFiles; 71 | 72 | # If this is a PR then lint only js changed in the PR, if not a PR then lint them all. 73 | - name: Run eslint 74 | run: npx wp-scripts lint-js ${{ steps.changed-files.outputs.files || '' }} 75 | -------------------------------------------------------------------------------- /.github/workflows/security.yml: -------------------------------------------------------------------------------- 1 | name: Security 2 | 3 | on: 4 | # Run on all pushes and on all pull requests. 5 | push: 6 | pull_request: 7 | # Also run this workflow every Monday at 6:00. 8 | schedule: 9 | - cron: '0 6 * * 1' 10 | # Allow manually triggering the workflow. 11 | workflow_dispatch: 12 | 13 | # Cancels all previous workflow runs for the same branch that have not yet completed. 14 | concurrency: 15 | # The concurrency group contains the workflow name and the branch name. 16 | group: ${{ github.workflow }}-${{ github.ref }} 17 | cancel-in-progress: true 18 | 19 | jobs: 20 | security: 21 | name: 'Security check' 22 | runs-on: ubuntu-latest 23 | 24 | # Don't run the cronjob in this workflow on forks. 25 | if: github.event_name != 'schedule' || (github.event_name == 'schedule' && github.repository_owner == 'Yoast') 26 | 27 | steps: 28 | - name: Checkout code 29 | uses: actions/checkout@v3 30 | 31 | # This action checks the `composer.lock` file against known security vulnerabilities in the dependencies. 32 | # https://github.com/marketplace/actions/the-php-security-checker 33 | - name: Run Security Check 34 | uses: symfonycorp/security-checker-action@v5 -------------------------------------------------------------------------------- /.github/workflows/wp-version-checker.yml: -------------------------------------------------------------------------------- 1 | name: "WordPress version checker" 2 | on: 3 | push: 4 | branches: 5 | - develop 6 | - main 7 | schedule: 8 | - cron: '0 0 * * *' 9 | 10 | permissions: 11 | issues: write 12 | 13 | jobs: 14 | wordpress-version-checker: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: WordPress version checker 18 | uses: skaut/wordpress-version-checker@v2.0.0 19 | with: 20 | repo-token: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Exclude these files from the git repo 2 | .sass-cache/ 3 | edac_log.txt 4 | #composer.lock 5 | package-lock.json 6 | 7 | # Hidden system files 8 | *.DS_Store 9 | *[Tt]humbs.db 10 | *.Trashes 11 | prepros-6.config 12 | vendor 13 | node_modules 14 | build 15 | dist 16 | .env 17 | 18 | #accessibility-checker-wp-env 19 | .auth 20 | .wp-env.json 21 | .wp-env 22 | 23 | 24 | .phpunit.result.cache 25 | 26 | # Exclude coverage reports 27 | coverage/ -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npm run lint-staged-precommit 2 | -------------------------------------------------------------------------------- /.phpcs.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | Generally-applicable sniffs for WordPress plugins. 4 | 5 | 6 | . 7 | /vendor/ 8 | /node_modules/ 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | */tests/* 51 | 52 | 53 | */tests/* 54 | 55 | 56 | */tests/* 57 | 58 | 59 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | dist: trusty 3 | 4 | language: php 5 | 6 | notifications: 7 | email: 8 | on_success: never 9 | on_failure: change 10 | 11 | branches: 12 | only: 13 | - master 14 | 15 | cache: 16 | directories: 17 | - $HOME/.composer/cache 18 | 19 | matrix: 20 | include: 21 | - php: 7.4 22 | env: WP_VERSION=latest 23 | - php: 7.3 24 | env: WP_VERSION=latest 25 | - php: 7.2 26 | env: WP_VERSION=latest 27 | - php: 7.1 28 | env: WP_VERSION=latest 29 | - php: 7.0 30 | env: WP_VERSION=latest 31 | - php: 5.6 32 | env: WP_VERSION=latest 33 | - php: 5.6 34 | env: WP_VERSION=trunk 35 | - php: 5.6 36 | env: WP_TRAVISCI=phpcs 37 | 38 | before_script: 39 | - export PATH="$HOME/.composer/vendor/bin:$PATH" 40 | - | 41 | if [ -f ~/.phpenv/versions/$(phpenv version-name)/etc/conf.d/xdebug.ini ]; then 42 | phpenv config-rm xdebug.ini 43 | else 44 | echo "xdebug.ini does not exist" 45 | fi 46 | - | 47 | if [[ ! -z "$WP_VERSION" ]] ; then 48 | bash bin/install-wp-tests.sh wordpress_test root '' localhost $WP_VERSION 49 | composer global require "phpunit/phpunit=4.8.*|5.7.*" 50 | fi 51 | - | 52 | if [[ "$WP_TRAVISCI" == "phpcs" ]] ; then 53 | composer global require wp-coding-standards/wpcs 54 | composer global require phpcompatibility/php-compatibility 55 | composer global require phpcompatibility/phpcompatibility-paragonie 56 | composer global require phpcompatibility/phpcompatibility-wp 57 | phpcs --config-set installed_paths $HOME/.composer/vendor/wp-coding-standards/wpcs,$HOME/.composer/vendor/phpcompatibility/php-compatibility,$HOME/.composer/vendor/phpcompatibility/phpcompatibility-paragonie,$HOME/.composer/vendor/phpcompatibility/phpcompatibility-wp 58 | fi 59 | 60 | script: 61 | - | 62 | if [[ ! -z "$WP_VERSION" ]] ; then 63 | phpunit 64 | WP_MULTISITE=1 phpunit 65 | fi 66 | - | 67 | if [[ "$WP_TRAVISCI" == "phpcs" ]] ; then 68 | phpcs 69 | fi 70 | -------------------------------------------------------------------------------- /admin/AdminPage/PageInterface.php: -------------------------------------------------------------------------------- 1 | meta_boxes = $meta_boxes; 35 | } 36 | 37 | /** 38 | * Initialize. 39 | * 40 | * @return void 41 | */ 42 | public function init(): void { 43 | 44 | $update_database = new Update_Database(); 45 | $update_database->init_hooks(); 46 | 47 | add_action( 'admin_enqueue_scripts', [ 'EDAC\Admin\Enqueue_Admin', 'enqueue' ] ); 48 | add_action( 'wp_trash_post', [ Purge_Post_Data::class, 'delete_post' ] ); 49 | add_action( 'save_post', [ Post_Save::class, 'delete_issue_data_on_post_trashing' ], 10, 3 ); 50 | 51 | $admin_notices = new Admin_Notices(); 52 | $admin_notices->init_hooks(); 53 | 54 | $widgets = new Widgets(); 55 | $widgets->init_hooks(); 56 | 57 | $site_health_info = new Information(); 58 | $site_health_info->init_hooks(); 59 | 60 | $this->init_ajax(); 61 | 62 | $this->meta_boxes->init_hooks(); 63 | } 64 | 65 | /** 66 | * Initialize ajax. 67 | * 68 | * @return void 69 | */ 70 | private function init_ajax(): void { 71 | if ( ! defined( 'DOING_AJAX' ) || ! DOING_AJAX ) { 72 | return; 73 | } 74 | 75 | $ajax = new Ajax(); 76 | $ajax->init_hooks(); 77 | 78 | $frontend_highlight = new Frontend_Highlight(); 79 | $frontend_highlight->init_hooks(); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /admin/class-meta-boxes.php: -------------------------------------------------------------------------------- 1 | post_type, $post_types, true ) ) { 40 | return; 41 | } 42 | 43 | // prevents first past of save_post due to meta boxes on post editor in gutenberg. 44 | if ( empty( $_POST ) ) { 45 | return; 46 | } 47 | 48 | // ignore revisions. 49 | if ( wp_is_post_revision( $post_ID ) ) { 50 | return; 51 | } 52 | 53 | // ignore autosaves. 54 | if ( wp_is_post_autosave( $post_ID ) ) { 55 | return; 56 | } 57 | 58 | // check if update. 59 | if ( ! $update ) { 60 | return; 61 | } 62 | 63 | // handle the case when the custom post is quick edited. 64 | if ( isset( $_POST['_inline_edit'] ) ) { 65 | $inline_edit = sanitize_text_field( $_POST['_inline_edit'] ); 66 | if ( wp_verify_nonce( $inline_edit, 'inlineeditnonce' ) ) { 67 | return; 68 | } 69 | } 70 | 71 | // Post in, or going to, trash. 72 | if ( 'trash' === $post->post_status ) { 73 | // Gutenberg does not fire the `wp_trash_post` action when moving posts to the 74 | // trash. Instead, it uses `rest_delete_{$post_type}` which passes a different shape 75 | // Instead of hooking in there for every post type supported the data gets purged 76 | // here instead which produces the same result. 77 | Purge_Post_Data::delete_post( $post_ID ); 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /admin/class-purge-post-data.php: -------------------------------------------------------------------------------- 1 | query( 32 | $wpdb->prepare( 33 | 'DELETE FROM %i WHERE postid = %d and siteid = %d', 34 | edac_get_valid_table_name( $wpdb->prefix . 'accessibility_checker' ), 35 | $post_id, 36 | get_current_blog_id() 37 | ) 38 | ); 39 | 40 | self::delete_post_meta( $post_id ); 41 | } 42 | 43 | /** 44 | * Delete post meta 45 | * 46 | * @since 1.10.0 47 | * 48 | * @param int $post_id ID of the post. 49 | * 50 | * @return void 51 | */ 52 | public static function delete_post_meta( int $post_id ) { 53 | 54 | if ( ! $post_id ) { 55 | return; 56 | } 57 | 58 | $post_meta = get_post_meta( $post_id ); 59 | if ( $post_meta ) { 60 | foreach ( $post_meta as $key => $value ) { 61 | if ( substr( $key, 0, 5 ) === '_edac' || substr( $key, 0, 6 ) === '_edacp' ) { 62 | delete_post_meta( $post_id, $key ); 63 | } 64 | } 65 | } 66 | } 67 | 68 | /** 69 | * Purge issues by post type 70 | * 71 | * @since 1.10.0 72 | * 73 | * @param string $post_type Post Type. 74 | * 75 | * @return bool|int|\mysqli_result|void 76 | */ 77 | public static function delete_cpt_posts( string $post_type ) { 78 | 79 | if ( ! $post_type || ! post_type_exists( $post_type ) ) { 80 | return; 81 | } 82 | 83 | global $wpdb; 84 | 85 | // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Safe variable used for table name, caching not required for one time operation. 86 | return $wpdb->query( 87 | $wpdb->prepare( 88 | "DELETE T1,T2 from $wpdb->postmeta as T1 JOIN %i as T2 ON T1.post_id = T2.postid WHERE T1.meta_key like %s and T2.siteid=%d and T2.type=%s", 89 | edac_get_valid_table_name( $wpdb->prefix . 'accessibility_checker' ), 90 | '_edac%', 91 | get_current_blog_id(), 92 | $post_type 93 | ) 94 | ); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /admin/class-update-database.php: -------------------------------------------------------------------------------- 1 | prefix . 'accessibility_checker'; 40 | 41 | $query = $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table_name ) ); 42 | // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Prepare above, Safe variable used for table name, caching not required for one time operation. 43 | if ( get_option( 'edac_db_version' ) !== EDAC_DB_VERSION || $wpdb->get_var( $query ) !== $table_name ) { 44 | 45 | $charset_collate = $wpdb->get_charset_collate(); 46 | $sql = "CREATE TABLE $table_name ( 47 | id bigint(20) NOT NULL AUTO_INCREMENT, 48 | postid bigint(20) NOT NULL, 49 | siteid text NOT NULL, 50 | type text NOT NULL, 51 | rule text NOT NULL, 52 | ruletype text NOT NULL, 53 | object mediumtext NOT NULL, 54 | recordcheck mediumint(9) NOT NULL, 55 | created timestamp NOT NULL default CURRENT_TIMESTAMP, 56 | user bigint(20) NOT NULL, 57 | ignre mediumint(9) NOT NULL, 58 | ignre_global mediumint(9) NOT NULL, 59 | ignre_user bigint(20) NULL, 60 | ignre_date timestamp NULL, 61 | ignre_comment mediumtext NULL, 62 | UNIQUE KEY id (id), 63 | KEY postid_index (postid) 64 | ) $charset_collate;"; 65 | 66 | require_once ABSPATH . 'wp-admin/includes/upgrade.php'; 67 | dbDelta( $sql ); 68 | 69 | } 70 | 71 | // Update database version option. 72 | update_option( 'edac_db_version', sanitize_text_field( EDAC_DB_VERSION ) ); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /admin/site-health/class-audit-history.php: -------------------------------------------------------------------------------- 1 | __( 'Accessibility Checker — Audit History', 'accessibility-checker' ), 33 | 'fields' => [ 34 | 'version' => [ 35 | 'label' => 'Version', 36 | 'value' => EDACAH_VERSION, 37 | ], 38 | 'ignores_db_table_count' => [ 39 | 'label' => 'DB Table Count', 40 | 'value' => edac_database_table_count( 'accessibility_checker_audit_history' ), 41 | ], 42 | ], 43 | ]; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /admin/site-health/class-information.php: -------------------------------------------------------------------------------- 1 | get_edac_data() ); 40 | } 41 | 42 | /** 43 | * Gets all of the data for the Site Health. 44 | * 45 | * @since 1.9.0 46 | * @return array 47 | */ 48 | private function get_edac_data() { 49 | $collectors = [ 50 | 'edac_free' => new Free(), 51 | ]; 52 | 53 | if ( defined( 'EDACP_VERSION' ) ) { 54 | $collectors['edac_pro'] = new Pro(); 55 | } 56 | 57 | if ( defined( 'EDACAH_VERSION' ) ) { 58 | $collectors['edac_audit_history'] = new Audit_History(); 59 | } 60 | 61 | $information = []; 62 | foreach ( $collectors as $key => $class ) { 63 | $information[ $key ] = $class->get(); 64 | } 65 | 66 | /** 67 | * Filter the debug information. 68 | * 69 | * Allows extensions to add their own debug information that's specific to EDAC. 70 | * 71 | * @since 1.6.10 72 | * 73 | * @param array $information The debug information. 74 | */ 75 | return apply_filters( 'edac_debug_information', $information ); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /assets/fonts/anww.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equalizedigital/accessibility-checker/7c52a7c53a6967dbf3d18f90e69b5a58a56f5f91/assets/fonts/anww.eot -------------------------------------------------------------------------------- /assets/fonts/anww.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Generated by IcoMoon 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /assets/fonts/anww.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equalizedigital/accessibility-checker/7c52a7c53a6967dbf3d18f90e69b5a58a56f5f91/assets/fonts/anww.ttf -------------------------------------------------------------------------------- /assets/fonts/anww.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equalizedigital/accessibility-checker/7c52a7c53a6967dbf3d18f90e69b5a58a56f5f91/assets/fonts/anww.woff -------------------------------------------------------------------------------- /assets/images/contrast icon blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equalizedigital/accessibility-checker/7c52a7c53a6967dbf3d18f90e69b5a58a56f5f91/assets/images/contrast icon blue.png -------------------------------------------------------------------------------- /assets/images/contrast icon navy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equalizedigital/accessibility-checker/7c52a7c53a6967dbf3d18f90e69b5a58a56f5f91/assets/images/contrast icon navy.png -------------------------------------------------------------------------------- /assets/images/contrast icon red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equalizedigital/accessibility-checker/7c52a7c53a6967dbf3d18f90e69b5a58a56f5f91/assets/images/contrast icon red.png -------------------------------------------------------------------------------- /assets/images/edac-emblem.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equalizedigital/accessibility-checker/7c52a7c53a6967dbf3d18f90e69b5a58a56f5f91/assets/images/edac-emblem.png -------------------------------------------------------------------------------- /assets/images/edac-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equalizedigital/accessibility-checker/7c52a7c53a6967dbf3d18f90e69b5a58a56f5f91/assets/images/edac-logo.png -------------------------------------------------------------------------------- /assets/images/highlight-icon-error.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /assets/images/highlight-icon-ignored.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /assets/images/highlight-icon-warning.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /assets/images/ignore icon blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equalizedigital/accessibility-checker/7c52a7c53a6967dbf3d18f90e69b5a58a56f5f91/assets/images/ignore icon blue.png -------------------------------------------------------------------------------- /assets/images/ignore icon navy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equalizedigital/accessibility-checker/7c52a7c53a6967dbf3d18f90e69b5a58a56f5f91/assets/images/ignore icon navy.png -------------------------------------------------------------------------------- /assets/images/ignore-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | 10 | 15 | 19 | 22 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /assets/images/readability icon blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equalizedigital/accessibility-checker/7c52a7c53a6967dbf3d18f90e69b5a58a56f5f91/assets/images/readability icon blue.png -------------------------------------------------------------------------------- /assets/images/readability icon navy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equalizedigital/accessibility-checker/7c52a7c53a6967dbf3d18f90e69b5a58a56f5f91/assets/images/readability icon navy.png -------------------------------------------------------------------------------- /assets/images/readability icon white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equalizedigital/accessibility-checker/7c52a7c53a6967dbf3d18f90e69b5a58a56f5f91/assets/images/readability icon white.png -------------------------------------------------------------------------------- /assets/images/warning icon yellow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equalizedigital/accessibility-checker/7c52a7c53a6967dbf3d18f90e69b5a58a56f5f91/assets/images/warning icon yellow.png -------------------------------------------------------------------------------- /includes/activation.php: -------------------------------------------------------------------------------- 1 | 1 ) { 29 | return; 30 | } 31 | 32 | Accessibility_Statement::add_page(); 33 | } 34 | -------------------------------------------------------------------------------- /includes/classes/Fixes/Fix/AddFileSizeAndTypeToLinkedFilesFix.php: -------------------------------------------------------------------------------- 1 | get_slug() ] = [ 75 | 'type' => 'checkbox', 76 | 'label' => esc_html__( 'Add File Size & Type To Links', 'accessibility-checker' ), 77 | 'labelledby' => 'add_file_size_and_type_to_linked_files', 78 | 'description' => esc_html__( 'Add the file size and type to linked files that may trigger a download.', 'accessibility-checker' ), 79 | 'upsell' => isset( $this->is_pro ) && $this->is_pro ? false : true, 80 | 'fix_slug' => $this->get_slug(), 81 | 'help_id' => 8492, 82 | ]; 83 | 84 | return $fields; 85 | } 86 | 87 | /** 88 | * Run the fix. 89 | */ 90 | public function run(): void { 91 | // Intentionally left empty. 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /includes/classes/Fixes/Fix/AddMissingOrEmptyPageTitleFix.php: -------------------------------------------------------------------------------- 1 | tag if empty or missing. 14 | * 15 | * @since 1.9.0 16 | */ 17 | class AddMissingOrEmptyPageTitleFix implements FixInterface { 18 | 19 | /** 20 | * The slug of the fix. 21 | * 22 | * @return string 23 | */ 24 | public static function get_slug(): string { 25 | return 'missing_or_empty_page_title'; 26 | } 27 | 28 | /** 29 | * The nicename for the fix. 30 | * 31 | * @return string 32 | */ 33 | public static function get_nicename(): string { 34 | return __( 'Add Missing or Empty Page Titles', 'accessibility-checker' ); 35 | } 36 | 37 | /** 38 | * The fancyname for the fix. 39 | * 40 | * @return string 41 | */ 42 | public static function get_fancyname(): string { 43 | return __( 'Set Page HTML Titles', 'accessibility-checker' ); 44 | } 45 | 46 | /** 47 | * The type of the fix. 48 | * 49 | * @return string 50 | */ 51 | public static function get_type(): string { 52 | return 'none'; 53 | } 54 | 55 | /** 56 | * Registers everything needed for the fix. 57 | * 58 | * @return void 59 | */ 60 | public function register(): void { 61 | add_filter( 62 | 'edac_filter_fixes_settings_fields', 63 | [ $this, 'get_fields_array' ], 64 | ); 65 | } 66 | 67 | /** 68 | * Get the settings fields for the fix. 69 | * 70 | * @param array $fields The array of fields that are already registered, if any. 71 | * 72 | * @return array 73 | */ 74 | public function get_fields_array( array $fields = [] ): array { 75 | $fields['edac_fix_add_missing_or_empty_page_title'] = [ 76 | 'type' => 'checkbox', 77 | 'label' => esc_html__( 'Add Missing Page Title', 'accessibility-checker' ), 78 | 'labelledby' => 'add_missing_or_empty_page_title', 79 | // translators: %1$s: a code tag with a title tag. 80 | 'description' => sprintf( __( 'Add a %1$s tag to the page if it\'s missing or empty.', 'accessibility-checker' ), '<title>' ), 81 | 'upsell' => isset( $this->is_pro ) && $this->is_pro ? false : true, 82 | 'fix_slug' => $this->get_slug(), 83 | 'group_name' => $this->get_nicename(), 84 | 'help_id' => 8490, 85 | ]; 86 | 87 | return $fields; 88 | } 89 | 90 | /** 91 | * Run the fix for adding a missing or empty page title. 92 | * 93 | * @return void 94 | */ 95 | public function run() { 96 | // Intentionally left empty. 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /includes/classes/Fixes/Fix/BlockPDFUploadsFix.php: -------------------------------------------------------------------------------- 1 | esc_html__( 'Block PDF Uploads', 'accessibility-checker' ), 69 | 'type' => 'checkbox', 70 | 'labelledby' => 'block_pdf_uploads', 71 | // translators: %1$s: a code tag with the capability name. 72 | 'description' => sprintf( __( 'Restrict PDF uploads for users without the %1$s capability (allowed for admins by default).', 'accessibility-checker' ), 'edac_upload_pdf' ), 73 | 'upsell' => isset( $this->is_pro ) && $this->is_pro ? false : true, 74 | 'fix_slug' => $this->get_slug(), 75 | 'help_id' => 8486, 76 | ]; 77 | 78 | return $fields; 79 | } 80 | 81 | /** 82 | * Run the fix. 83 | */ 84 | public function run() { 85 | // Intentionally empty - this run method should be implemented in an extension class. 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /includes/classes/Fixes/FixInterface.php: -------------------------------------------------------------------------------- 1 | %2$s', 49 | esc_attr__( 'Accessibility Checker (opens in a new window)', 'accessibility-checker' ), 50 | esc_html__( 'Accessibility Checker', 'accessibility-checker' ) 51 | ) 52 | ); 53 | } 54 | 55 | if ( $include_statement_link && $policy_page ) { 56 | $statement .= ( ! empty( $statement ) ? ' ' : '' ) . sprintf( 57 | // translators: %1$s is a link to the accessibility policy page, with text "Accessibility Policy". 58 | esc_html__( 'Read our %s', 'accessibility-checker' ), 59 | '' . esc_html__( 'Accessibility Policy', 'accessibility-checker' ) . '.' 60 | ); 61 | } 62 | 63 | return $statement; 64 | } 65 | 66 | /** 67 | * Output accessibility statement 68 | * 69 | * @return void 70 | */ 71 | public function output_accessibility_statement() { 72 | $statement = $this->get_accessibility_statement(); 73 | if ( ! empty( $statement ) ) { 74 | echo '

' . wp_kses_post( $statement ) . '

'; 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /includes/classes/class-lazyload-filter.php: -------------------------------------------------------------------------------- 1 | init(); 28 | } else { 29 | $this->init(); 30 | } 31 | 32 | // The REST api must load if admin or not. 33 | $rest_api = new REST_Api(); 34 | $rest_api->init_hooks(); 35 | 36 | $this->register_fixes_manager(); 37 | 38 | // When WP CLI is enabled, load the CLI commands. 39 | if ( defined( 'WP_CLI' ) && WP_CLI ) { 40 | $cli = new BootstrapCLI(); 41 | $cli->register(); 42 | } 43 | } 44 | 45 | /** 46 | * Initialize. 47 | * 48 | * @return void 49 | */ 50 | private function init() { 51 | 52 | add_action( 'wp_enqueue_scripts', [ 'EDAC\Inc\Enqueue_Frontend', 'enqueue' ] ); 53 | 54 | $accessibility_statement = new Accessibility_Statement(); 55 | $accessibility_statement->init_hooks(); 56 | 57 | $simplified_summary = new Simplified_Summary(); 58 | $simplified_summary->init_hooks(); 59 | 60 | $lazyload_filter = new Lazyload_Filter(); 61 | $lazyload_filter->init_hooks(); 62 | } 63 | 64 | /** 65 | * Register the FixesManager. 66 | * 67 | * @return void 68 | */ 69 | public function register_fixes_manager() { 70 | add_action( 'plugins_loaded', [ $this, 'init_fixes_manager' ], 20 ); 71 | } 72 | 73 | /** 74 | * Init the FixesManager. 75 | * 76 | * This is done on the plugins_loaded hook with a priority of 20 to ensure that fixes that 77 | * rely on running early, like on init or before init, can be hooked in and ready to go. 78 | * Fixes should be registered to the manager using the the plugins_loaded hook with a 79 | * priority of less than 20. 80 | * 81 | * @return void 82 | */ 83 | public function init_fixes_manager() { 84 | $fixes_manager = FixesManager::get_instance(); 85 | $fixes_manager->register_fixes(); 86 | add_action( 'rest_api_init', [ $fixes_manager, 'register_rest_routes' ] ); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /includes/classes/class-simplified-summary.php: -------------------------------------------------------------------------------- 1 | simplified_summary_markup( get_the_ID() ); 40 | $simplified_summary_position = get_option( 'edac_simplified_summary_position', $default = false ); 41 | 42 | if ( $simplified_summary ) { 43 | if ( 'before' === $simplified_summary_position ) { 44 | return $simplified_summary . $content; 45 | } 46 | if ( 'after' === $simplified_summary_position ) { 47 | return $content . $simplified_summary; 48 | } 49 | } 50 | return $content; 51 | } 52 | 53 | /** 54 | * Simplified summary markup 55 | * 56 | * @param int $post Post ID. 57 | * @return string 58 | */ 59 | public function simplified_summary_markup( $post ) { 60 | $simplified_summary = get_post_meta( $post, '_edac_simplified_summary', true ) 61 | ? get_post_meta( $post, '_edac_simplified_summary', true ) 62 | : ''; 63 | 64 | /** 65 | * Filter the heading that gets output before the simplified summary inside an

tag. 66 | * 67 | * @since 1.4.0 68 | * 69 | * @param string $simplified_summary_heading The simplified summary heading. 70 | */ 71 | $simplified_summary_heading = apply_filters( 72 | 'edac_filter_simplified_summary_heading', 73 | esc_html__( 'Simplified Summary', 'accessibility-checker' ) 74 | ); 75 | 76 | if ( $simplified_summary ) { 77 | return '

' . wp_kses_post( $simplified_summary_heading ) . '

' . wp_kses_post( $simplified_summary ) . '

'; 78 | } 79 | return ''; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /includes/deactivation.php: -------------------------------------------------------------------------------- 1 | 11 |
12 | 13 |
14 |

15 |
16 | 20 | 21 |
22 |

23 |
24 | 25 |
26 |
27 | 32 |
33 | 34 |
35 | 36 |
37 |
38 | -------------------------------------------------------------------------------- /partials/custom-meta-box.php: -------------------------------------------------------------------------------- 1 | 9 |
10 |

11 |
12 |
    13 |
  • 14 | 23 |
  • 24 |
  • 25 | 33 |
  • 34 |
  • 35 | 43 |
  • 44 |
45 | 46 | 53 |
54 |
60 | 67 | 74 |
75 | -------------------------------------------------------------------------------- /partials/pro-callout.php: -------------------------------------------------------------------------------- 1 | 9 |
10 | <?php esc_attr_e( 'Equalize Digital Logo', 'accessibility-checker' ); ?> 15 |

16 | 17 |

18 |
19 |
    20 |
  • 21 |
  • 22 |
  • 23 |
  • 24 |
  • 25 |
  • 26 |
  • 27 |
  • 28 |
29 |
30 |
31 | 36 | 37 | 38 |
39 | 40 | 41 |
42 | 46 | 47 | 48 | 49 |
50 | 51 | 52 | -------------------------------------------------------------------------------- /patches/@wordpress+env+8.8.0.patch: -------------------------------------------------------------------------------- 1 | # Update to give us permission to modify port routing. 2 | 3 | diff --git a/node_modules/@wordpress/env/lib/commands/run.js b/node_modules/@wordpress/env/lib/commands/run.js 4 | index def29b6..fc127ce 100644 5 | --- a/node_modules/@wordpress/env/lib/commands/run.js 6 | +++ b/node_modules/@wordpress/env/lib/commands/run.js 7 | @@ -85,6 +85,7 @@ function spawnCommandDirectly( config, container, command, envCwd, spinner ) { 8 | envCwd, 9 | '--user', 10 | hostUser.fullUser, 11 | + '--privileged' 12 | ]; 13 | 14 | if ( ! process.stdout.isTTY ) { 15 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 2 3 | paths: 4 | - . 5 | scanDirectories: 6 | - %currentWorkingDirectory%/vendor 7 | excludePaths: 8 | analyse: 9 | - %currentWorkingDirectory%/vendor/* 10 | - %currentWorkingDirectory%/node_modules/* 11 | - %currentWorkingDirectory%/tests/* 12 | fileExtensions: 13 | - php 14 | -------------------------------------------------------------------------------- /phpunit.dev.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 12 | ./tests/ 13 | ./tests/ 14 | 15 | 16 | 17 | 18 | ./ 19 | 20 | 21 | ./tests/ 22 | ./vendor/ 23 | ./node_modules/ 24 | ./dist/ 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 12 | ./tests/ 13 | ./tests/ 14 | 15 | 16 | 17 | 18 | ./ 19 | 20 | 21 | ./tests/ 22 | ./vendor/ 23 | ./node_modules/ 24 | ./dist/ 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /run-composer.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | php update-composer-config.php 3 | 4 | composer update -------------------------------------------------------------------------------- /scripts/dist.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Define the flag variable and set default value 4 | KEEP_BUILD_FOLDER=false 5 | 6 | # Parse command-line options 7 | while getopts ":-:" opt; do 8 | case $opt in 9 | -) 10 | case "${OPTARG}" in 11 | keep-build-folder) 12 | val="${!OPTIND}"; OPTIND=$(( $OPTIND + 1 )) 13 | KEEP_BUILD_FOLDER=$val 14 | ;; 15 | *) 16 | ;; 17 | esac;; 18 | *) 19 | ;; 20 | esac 21 | done 22 | 23 | # Ensure ./dist directory exists, if not create it 24 | mkdir -p ./dist 25 | 26 | # Run the wp-scripts command that produces the zip 27 | npx wp-scripts plugin-zip 28 | 29 | # Always clear the dist/accessibility-checker folder before unzipping 30 | rm -rfd ./dist/accessibility-checker 31 | 32 | # Unzip the zip into its own folder so we can repackage with some changes 33 | unzip accessibility-checker.zip -d ./dist/accessibility-checker 34 | 35 | # The plugin-zip commands includes package.json, which is not needed for the plugin, so remove it and the repo README 36 | rm ./dist/accessibility-checker/package.json 37 | rm ./dist/accessibility-checker/README.md 38 | 39 | # Remove the unneeded (almost 1MB) tests folder for textstatistics package 40 | rm ./dist/accessibility-checker/vendor/davechild/textstatistics/tests -r 41 | 42 | # Remove the original zip 43 | rm accessibility-checker.zip 44 | 45 | # Get the string at the end of the line starting with ' * Version:' from the main plugin file 46 | VERSION=$(grep " * Version:" ./dist/accessibility-checker/accessibility-checker.php | grep -o '[0-9.]*\(-[a-zA-Z0-9.]*\)*') 47 | 48 | # Move into the dist folder and zip the plugin's folder 49 | cd ./dist 50 | zip -r accessibility-checker-$VERSION.zip accessibility-checker 51 | 52 | # Drop back into the original dir 53 | cd .. 54 | 55 | # Skip this step if the 'keep-build-folder' flag is true 56 | if [ "$KEEP_BUILD_FOLDER" = false ] ; then 57 | rm -r ./dist/accessibility-checker 58 | fi 59 | -------------------------------------------------------------------------------- /scripts/hard-reset.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "You have chosen to remove and reinstall the wordpress dev and testing environments." 4 | echo -n "This will delete the database and content files. Are you sure want to continue? (yes/NO): " 5 | 6 | # Read user input into the variable "response" 7 | read response 8 | 9 | # Check if the response is "yes" and take action accordingly 10 | if [ "$response" = "yes" ]; then 11 | echo "Ok. Removing and reinstalling the wordpress environments." 12 | echo "y" | npx wp-env destroy 13 | rm .wp-env.json 14 | rm -r ./.wp-env 15 | npm install #Note, this also runs the prepare script. 16 | else 17 | echo "Ok, no changes were made." 18 | fi 19 | 20 | -------------------------------------------------------------------------------- /scripts/prepare.sh: -------------------------------------------------------------------------------- 1 | npm run start --update 2 | npm run stop 3 | 4 | mkdir -p ./dist 5 | mkdir -p ./tests/e2e/.auth 6 | mkdir -p ./patches 7 | mkdir -p ./.wp-env 8 | 9 | # note, when pulling from github you must include the commit hash 10 | # https://stackoverflow.com/questions/22608398/composer-update-not-pulling-latest-dev-master 11 | rm composer.lock 12 | composer install 13 | 14 | cp -r -n ./vendor/equalizedigital/accessibility-checker-wp-env/.wp-env/ ./.wp-env 15 | cp -r -n ./vendor/equalizedigital/accessibility-checker-wp-env/.wp-env.json ./ 16 | cp -r ./vendor/equalizedigital/accessibility-checker-wp-env/patches/* ./patches/ 17 | 18 | npm run start 19 | ./.wp-env/scripts/init.sh 20 | npm run build 21 | 22 | -------------------------------------------------------------------------------- /src/admin/fixes-page/conditional-disable-settings.js: -------------------------------------------------------------------------------- 1 | const setInputStates = () => { 2 | const elements = document.querySelectorAll( '[data-condition]' ); 3 | 4 | elements.forEach( ( element ) => { 5 | const conditionId = element.getAttribute( 'data-condition' ); 6 | 7 | const conditionElement = document.getElementById( conditionId ); 8 | 9 | if ( conditionElement ) { 10 | if ( conditionElement.tagName.toLowerCase() === 'input' ) { 11 | if ( ( conditionElement.type === 'text' && conditionElement.value.trim() !== '' ) || 12 | ( conditionElement.type === 'checkbox' && conditionElement.checked ) ) { 13 | element.disabled = false; 14 | element.closest( 'tr' ).classList.remove( 'edac-fix--hidden' ); 15 | } else { 16 | element.disabled = true; 17 | element.closest( 'tr' ).classList.add( 'edac-fix--hidden' ); 18 | } 19 | } 20 | } 21 | } ); 22 | }; 23 | 24 | export const initFixesInputStateHandler = () => { 25 | setInputStates(); 26 | 27 | // Find all checkboxes inside the form. 28 | const checkboxes = document.querySelectorAll( '.edac-settings form input[type="checkbox"]' ); 29 | checkboxes.forEach( ( checkbox ) => { 30 | checkbox.addEventListener( 'change', () => { 31 | setInputStates(); 32 | } ); 33 | } ); 34 | }; 35 | 36 | -------------------------------------------------------------------------------- /src/admin/fixes-page/conditional-required-settings.js: -------------------------------------------------------------------------------- 1 | // if element with id edac_fix_add_skip_link is checked then force element with id edac_fix_add_skip_link_target_id to be required 2 | 3 | export const initRequiredSetup = () => { 4 | document.querySelectorAll( '[data-required_when]' ).forEach( ( element ) => { 5 | const conditionId = element.getAttribute( 'data-required_when' ); 6 | 7 | const conditionElement = document.getElementById( conditionId ); 8 | 9 | if ( conditionElement ) { 10 | setRequiredState( conditionElement.checked, element.id ); 11 | conditionElement.addEventListener( 'change', ( event ) => { 12 | // find any element that points to this element id and set it to required 13 | const targets = document.querySelectorAll( `[data-required_when="${ conditionId }"]` ); 14 | targets.forEach( ( target ) => { 15 | setRequiredState( event.target.checked, target.id ); 16 | } ); 17 | } ); 18 | } 19 | } ); 20 | }; 21 | 22 | const setRequiredState = ( checked, elementId ) => { 23 | // if this is checked then force the target id to be required 24 | const targetElement = document.getElementById( elementId ); 25 | targetElement.required = checked; 26 | }; 27 | -------------------------------------------------------------------------------- /src/admin/fixes-page/pro-callout.js: -------------------------------------------------------------------------------- 1 | import { __ } from '@wordpress/i18n'; 2 | 3 | export const inlineFixesProUpsell = () => { 4 | // find elements with 'edac-fix--upsell' class 5 | const upsellElements = document.querySelectorAll( '.edac-fix--upsell' ); 6 | 7 | // loop through each element 8 | upsellElements.forEach( ( element ) => { 9 | // create a link with the upsell url 10 | const upsellLink = document.createElement( 'a' ); 11 | const url = window.edac_script_vars?.fixesProUrl || 'https://equalizedigital.com/accessibility-checker/pricing/'; 12 | upsellLink.href = url.replace( '__fix__', element.querySelector( 'input' )?.getAttribute( 'name' ) ); 13 | upsellLink.target = '_blank'; 14 | upsellLink.rel = 'noopener noreferrer'; 15 | upsellLink.textContent = __( 'Get Pro' ); 16 | upsellLink.classList.add( 'edac-fix--upsell-link' ); 17 | upsellLink.setAttribute( 'aria-label', __( 'Get Pro to unlock this feature, opens in a new window.', 'accessibility-checker' ) ); 18 | 19 | const tableHead = element.closest( 'tr' )?.querySelector( 'th' ); 20 | if ( ! tableHead ) { 21 | return; 22 | } 23 | tableHead.appendChild( document.createTextNode( ' ' ) ); 24 | tableHead.appendChild( upsellLink ); 25 | } ); 26 | }; 27 | -------------------------------------------------------------------------------- /src/admin/images/checkmark icon green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equalizedigital/accessibility-checker/7c52a7c53a6967dbf3d18f90e69b5a58a56f5f91/src/admin/images/checkmark icon green.png -------------------------------------------------------------------------------- /src/admin/images/contrast icon white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equalizedigital/accessibility-checker/7c52a7c53a6967dbf3d18f90e69b5a58a56f5f91/src/admin/images/contrast icon white.png -------------------------------------------------------------------------------- /src/admin/images/error icon red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equalizedigital/accessibility-checker/7c52a7c53a6967dbf3d18f90e69b5a58a56f5f91/src/admin/images/error icon red.png -------------------------------------------------------------------------------- /src/admin/images/error icon white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equalizedigital/accessibility-checker/7c52a7c53a6967dbf3d18f90e69b5a58a56f5f91/src/admin/images/error icon white.png -------------------------------------------------------------------------------- /src/admin/images/ignore icon white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equalizedigital/accessibility-checker/7c52a7c53a6967dbf3d18f90e69b5a58a56f5f91/src/admin/images/ignore icon white.png -------------------------------------------------------------------------------- /src/admin/images/list-check.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equalizedigital/accessibility-checker/7c52a7c53a6967dbf3d18f90e69b5a58a56f5f91/src/admin/images/list-check.png -------------------------------------------------------------------------------- /src/admin/images/warning icon navy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equalizedigital/accessibility-checker/7c52a7c53a6967dbf3d18f90e69b5a58a56f5f91/src/admin/images/warning icon navy.png -------------------------------------------------------------------------------- /src/admin/images/warning icon white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equalizedigital/accessibility-checker/7c52a7c53a6967dbf3d18f90e69b5a58a56f5f91/src/admin/images/warning icon white.png -------------------------------------------------------------------------------- /src/admin/images/welcome-screenshot-medium.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equalizedigital/accessibility-checker/7c52a7c53a6967dbf3d18f90e69b5a58a56f5f91/src/admin/images/welcome-screenshot-medium.png -------------------------------------------------------------------------------- /src/admin/images/welcome-screenshot-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equalizedigital/accessibility-checker/7c52a7c53a6967dbf3d18f90e69b5a58a56f5f91/src/admin/images/welcome-screenshot-small.png -------------------------------------------------------------------------------- /src/admin/images/welcome-screenshot-standard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equalizedigital/accessibility-checker/7c52a7c53a6967dbf3d18f90e69b5a58a56f5f91/src/admin/images/welcome-screenshot-standard.png -------------------------------------------------------------------------------- /src/common/sass/_helpers.scss: -------------------------------------------------------------------------------- 1 | @use "variables"; 2 | 3 | @mixin breakpoint($point) { 4 | @if $point == xs { 5 | @media (min-width: variables.$small-screen-width) { 6 | @content; 7 | } 8 | } @else if $point == sm { 9 | @media (min-width: variables.$medium-screen-width) { 10 | @content; 11 | } 12 | } @else if $point == md { 13 | @media (min-width: variables.$standard-screen-width) { 14 | @content; 15 | } 16 | } @else if $point == lg { 17 | @media (min-width: variables.$large-screen-width) { 18 | @content; 19 | } 20 | } @else if $point == xl { 21 | @media (min-width: variables.$extra-large-screen-width) { 22 | @content; 23 | } 24 | } @else if $point == retina { 25 | @media only screen and (-webkit-min-device-pixel-ratio: 2), 26 | only screen and (min-device-pixel-ratio: 2) { 27 | @content; 28 | } 29 | } 30 | } 31 | 32 | @mixin transition($transition...) { 33 | -webkit-transition: $transition; 34 | -moz-transition: $transition; 35 | -ms-transition: $transition; 36 | -o-transition: $transition; 37 | transition: $transition; 38 | } -------------------------------------------------------------------------------- /src/common/sass/_variables.scss: -------------------------------------------------------------------------------- 1 | $extra-large-screen-width: 1400px; 2 | $large-screen-width: 1140px; 3 | $standard-screen-width: 960px; 4 | $medium-screen-width: 768px; 5 | $small-screen-width: 600px; 6 | $extra-small-screen-width: 375px; 7 | 8 | $color-white: #ffffff; 9 | $color-black: #000000; 10 | $color-gray-lightest: #f8f8f8; 11 | $color-gray-light: #e2e4e7; 12 | $color-dark-gray: #484848; 13 | $color-gray: #737373; 14 | $color-red: #b30f0f; 15 | $color-green: #006600; 16 | $color-yellow: #f3cd1e; 17 | $color-blue-dark: #072446; 18 | $color-blue: #3273aa; 19 | $color-orange: #f3cd1e; 20 | 21 | $font-size-xxx-large: 3rem; 22 | $font-size-xx-large: 2rem; 23 | $font-size-x-large: 1.3rem; 24 | $font-size-large: 1rem; 25 | $font-size-medium: .875rem; 26 | $font-size-small: .83rem; 27 | $font-size-x-small: .75rem; 28 | -------------------------------------------------------------------------------- /src/editorApp/helpers.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable padded-blocks, no-multiple-empty-lines, no-console */ 2 | 3 | import { settings } from './settings'; 4 | 5 | export const info = ( message ) => { 6 | if ( settings.INFO_ENABLED ) { 7 | console.info( message ); 8 | } 9 | }; 10 | 11 | export const debug = ( message ) => { 12 | if ( settings.DEBUG_ENABLED ) { 13 | if ( location.href !== window.top.location.href ) { 14 | console.debug( 'DEBUG [ ' + location.href + ' ]' ); 15 | } 16 | if ( typeof message !== 'object' ) { 17 | console.debug( 'DEBUG: ' + message ); 18 | } else { 19 | console.debug( message ); 20 | } 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /src/editorApp/settings.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable padded-blocks, no-multiple-empty-lines */ 2 | /* global edacEditorApp */ 3 | 4 | let debug = false; 5 | 6 | if ( typeof ( edacEditorApp ) !== 'undefined' ) { 7 | debug = edacEditorApp.debug === '1'; 8 | } 9 | 10 | export const settings = { 11 | JS_SCAN_ENABLED: true, 12 | INFO_ENABLED: debug, 13 | DEBUG_ENABLED: debug, 14 | }; 15 | 16 | -------------------------------------------------------------------------------- /src/emailOptIn/modal.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Handle the opt-in modal for first time visitors to welcome page. 3 | * 4 | * This relies on the Thickbox library that is included in WordPress core which relies on jQuery. 5 | */ 6 | 7 | /* global tb_show, tb_remove */ 8 | 9 | import { createFocusTrap } from 'focus-trap'; 10 | 11 | // Ensure the global variable is defined. 12 | window.edac_email_opt_in_form = window.edac_email_opt_in_form || {}; 13 | 14 | export const initOptInModal = () => { 15 | window.onload = function() { 16 | window.addEventListener( 'mousemove', triggerModal, { once: true } ); 17 | window.addEventListener( 'scroll', triggerModal, { once: true } ); 18 | }; 19 | }; 20 | 21 | const triggerModal = ( () => { 22 | let hasRun = false; 23 | 24 | return () => { 25 | if ( hasRun ) { 26 | return; 27 | } 28 | hasRun = true; 29 | 30 | tb_show( 'Accessibility Checker', '#TB_inline?width=600&inlineId=edac-opt-in-modal', null ); 31 | 32 | // Loop and check for the close button before trying to bind the focus trap. 33 | let attempts = 0; 34 | const intervalId = setInterval( () => { 35 | if ( bindFocusTrap() ) { 36 | clearInterval( intervalId ); 37 | } 38 | // Some browsers (firefox) have popup blocking settings that makes the modal 39 | // content empty and so the button will never be found. To prevent users from 40 | // being stuck in a modal we will close it after 10 attempts. 41 | if ( attempts >= 10 ) { 42 | clearInterval( intervalId ); 43 | tb_remove(); 44 | return; 45 | } 46 | attempts++; 47 | }, 250 ); 48 | }; 49 | } )(); 50 | 51 | const bindFocusTrap = () => { 52 | const modal = document.getElementById( 'TB_window' ); 53 | const closeIcon = modal?.querySelector( '.tb-close-icon' ); 54 | if ( ! modal || ! closeIcon ) { 55 | return false; 56 | } 57 | 58 | closeIcon.setAttribute( 'aria-hidden', 'true' ); 59 | 60 | const focusTrap = createFocusTrap( modal ); 61 | focusTrap.activate(); 62 | 63 | jQuery( document ).one( 64 | 'tb_unload', 65 | function() { 66 | onModalClose( focusTrap ); 67 | } 68 | ); 69 | 70 | return true; 71 | }; 72 | 73 | const onModalClose = ( focusTrap ) => { 74 | focusTrap.deactivate(); 75 | 76 | fetch( window.edac_email_opt_in_form.ajaxurl + '?action=edac_email_opt_in_closed_modal_ajax' ) 77 | .then( ( r ) => r.json() ); 78 | }; 79 | -------------------------------------------------------------------------------- /src/emailOptIn/sass/email-opt-in.scss: -------------------------------------------------------------------------------- 1 | #_form_1_ { 2 | padding: 0; 3 | margin: 0; 4 | input { 5 | width: 100%; 6 | } 7 | } 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/frontendFixes/Fixes/langAndDirFix.js: -------------------------------------------------------------------------------- 1 | const LangAndDirFixData = window.edac_frontend_fixes?.lang_and_dir || { 2 | enabled: false, 3 | }; 4 | 5 | const LangAndDirFix = () => { 6 | if ( ! LangAndDirFixData.enabled ) { 7 | return; 8 | } 9 | 10 | const HTMLElement = document.querySelector( 'html' ); 11 | const lang = HTMLElement.getAttribute( 'lang' ); 12 | const dir = HTMLElement.getAttribute( 'dir' ); 13 | 14 | if ( ! lang || lang !== LangAndDirFixData.lang ) { 15 | HTMLElement.setAttribute( 'lang', LangAndDirFixData.lang ); 16 | } 17 | 18 | if ( ! dir || dir !== LangAndDirFixData.dir ) { 19 | HTMLElement.setAttribute( 'dir', LangAndDirFixData.dir ); 20 | } 21 | }; 22 | 23 | export default LangAndDirFix; 24 | -------------------------------------------------------------------------------- /src/frontendFixes/Fixes/metaViewportScalableFix.js: -------------------------------------------------------------------------------- 1 | const MetaViewportScalable = window.edac_frontend_fixes.meta_viewport_scalable || { 2 | enabled: false, 3 | }; 4 | 5 | const MetaViewportScalableFix = () => { 6 | if ( ! MetaViewportScalable.enabled ) { 7 | return; 8 | } 9 | 10 | // Get the meta viewport tag. 11 | const metaViewport = document.querySelector( 'meta[name="viewport"]' ); 12 | if ( metaViewport ) { 13 | // check if it has scalable set to no or 0. 14 | if ( ! metaViewport.content.match( /user\-scalable\s*=\s*(no|0)/i ) ) { 15 | return; 16 | } 17 | // remove the meta viewport tag as it blocks scaling. 18 | metaViewport.remove(); 19 | } 20 | 21 | // Create a new meta viewport tag. 22 | const newMetaViewport = document.createElement( 'meta' ); 23 | newMetaViewport.name = 'viewport'; 24 | newMetaViewport.content = 'width=device-width, initial-scale=1'; 25 | document.head.appendChild( newMetaViewport ); 26 | }; 27 | 28 | export default MetaViewportScalableFix; 29 | -------------------------------------------------------------------------------- /src/frontendFixes/Fixes/preventLinksOpeningNewWindowFix.js: -------------------------------------------------------------------------------- 1 | const preventLinksOpeningNewWindowFix = () => { 2 | const links = document.querySelectorAll( 'a[target="_blank"]:not(.edac-allow-new-tab)' ); 3 | links.forEach( ( link ) => { 4 | // If the link is in a container that allows new tabs, don't remove the target attribute. 5 | if ( link.closest( '.edac-allow-new-tab' ) ) { 6 | return; 7 | } 8 | link.removeAttribute( 'target' ); 9 | link.classList.add( 'edac-removed-target-blank' ); 10 | } ); 11 | }; 12 | 13 | export default preventLinksOpeningNewWindowFix; 14 | -------------------------------------------------------------------------------- /src/frontendFixes/Fixes/tabindexFix.js: -------------------------------------------------------------------------------- 1 | const RemoveTabindexFixData = window.edac_frontend_fixes?.tabindex || { 2 | enabled: false, 3 | }; 4 | 5 | const TabindexFix = () => { 6 | if ( ! RemoveTabindexFixData.enabled ) { 7 | return; 8 | } 9 | 10 | // Get all elements with a tabindex 11 | const elementsWithTabIndex = document.querySelectorAll( '[tabindex]' ); 12 | elementsWithTabIndex.forEach( ( element ) => { 13 | // Skip anchor tags without an href attribute or those with role="button" as they are not natively focusable 14 | if ( element.tagName === 'A' && ( ! element.hasAttribute( 'href' ) || element.getAttribute( 'role' ) === 'button' ) ) { 15 | element.setAttribute( 'tabindex', '0' ); 16 | return; 17 | } 18 | 19 | // If the tabindex is -1, skip. 20 | if ( element.getAttribute( 'tabindex' ) === '-1' ) { 21 | return; 22 | } 23 | 24 | // If the tabindex is positive then set it to 0. 25 | if ( element.getAttribute( 'tabindex' ) > 0 ) { 26 | element.setAttribute( 'tabindex', '0' ); 27 | element.classList.add( 'edac-focusable-modified' ); 28 | } 29 | } ); 30 | 31 | // Select all
elements with a role of "button" or elements with role="button", without href or tabindex 32 | const elementsToFocus = document.querySelectorAll( 33 | 'div[role="button"]:not([tabindex]), a[role="button"]:not([tabindex]):not([href])' 34 | ); 35 | 36 | // Loop through each element and add tabindex="0" to make it focusable. 37 | elementsToFocus.forEach( ( element ) => { 38 | // Don't add tabindex 0 to elements that alaredy have -1. 39 | if ( element.hasAttribute( 'tabindex' ) && element.getAttribute( 'tabindex' ) === '-1' ) { 40 | return; 41 | } 42 | element.setAttribute( 'tabindex', '0' ); 43 | element.classList.add( 'edac-focusable' ); 44 | } ); 45 | }; 46 | 47 | export default TabindexFix; 48 | -------------------------------------------------------------------------------- /src/frontendFixes/Fixes/underlineFix.js: -------------------------------------------------------------------------------- 1 | const ForceUnderlineFixData = window.edac_frontend_fixes?.underline || { 2 | enabled: false, 3 | }; 4 | 5 | const ForceUnderlineFix = () => { 6 | if ( ! ForceUnderlineFixData.enabled ) { 7 | return; 8 | } 9 | 10 | // Apply underline to any link that is not within a `nav` element. 11 | // Using JavaScript ensures this style takes precedence over conflicting CSS rules. 12 | const targets = document.querySelectorAll( ForceUnderlineFixData.target ); 13 | 14 | targets.forEach( function( target ) { 15 | // Early return if the element is inside a `nav` element or an element with role="navigation" 16 | if ( target.closest( 'nav' ) || target.closest( '[role="navigation"]' ) ) { 17 | return; 18 | } 19 | 20 | // Apply underline style 21 | target.style.textDecoration = 'underline'; 22 | 23 | // Store the original styles in data-* attributes 24 | target.setAttribute( 'data-original-outline', target.style.outlineWidth ); 25 | target.setAttribute( 'data-original-offset', target.style.outlineOffset ); 26 | target.setAttribute( 'data-original-color', target.style.outlineColor ); 27 | 28 | const textColor = target.style.color; // Capture the text color separately 29 | 30 | target.addEventListener( 'mouseenter', function() { 31 | target.style.textDecoration = 'none'; 32 | } ); 33 | 34 | target.addEventListener( 'mouseleave', function() { 35 | target.style.textDecoration = 'underline'; 36 | } ); 37 | 38 | target.addEventListener( 'focusin', function() { 39 | let newOutline = '2px'; 40 | if ( target.style.outlineWidth === '2px' ) { 41 | newOutline = '4px'; 42 | } 43 | // Increase outline thickness and adjust color and offset to ensure a visible focus indicator. 44 | target.style.outlineWidth = newOutline; 45 | target.style.outlineColor = textColor; 46 | target.style.outlineOffset = '2px'; 47 | } ); 48 | 49 | target.addEventListener( 'focusout', function() { 50 | // Restore the original styles from data-* attributes 51 | target.style.outlineWidth = target.getAttribute( 'data-original-outline' ); 52 | target.style.outlineColor = target.getAttribute( 'data-original-color' ); 53 | target.style.outlineOffset = target.getAttribute( 'data-original-offset' ); 54 | } ); 55 | } ); 56 | }; 57 | 58 | export default ForceUnderlineFix; 59 | -------------------------------------------------------------------------------- /src/frontendFixes/index.js: -------------------------------------------------------------------------------- 1 | const edacFrontendFixes = window.edac_frontend_fixes || {}; 2 | 3 | if ( edacFrontendFixes?.skip_link?.enabled ) { 4 | // lazy import the module 5 | import( /* webpackChunkName: "skip-link" */ './Fixes/skipLinkFix' ).then( ( skipLinkFix ) => { 6 | skipLinkFix.default(); 7 | } ); 8 | } 9 | 10 | if ( edacFrontendFixes?.lang_and_dir?.enabled ) { 11 | // lazy import the module 12 | import( /* webpackChunkName: "aria-hidden" */ './Fixes/langAndDirFix' ).then( ( langAndDirFix ) => { 13 | langAndDirFix.default(); 14 | } ); 15 | } 16 | 17 | if ( edacFrontendFixes?.tabindex?.enabled ) { 18 | // lazy import the module 19 | import( /* webpackChunkName: "tabindex" */ './Fixes/tabindexFix' ).then( ( tabindexFix ) => { 20 | tabindexFix.default(); 21 | } ); 22 | } 23 | 24 | if ( edacFrontendFixes?.meta_viewport_scalable?.enabled ) { 25 | // lazy import the module 26 | import( /* webpackChunkName: "meta-viewport-scalable" */ './Fixes/metaViewportScalableFix' ).then( ( metaViewportScalableFix ) => { 27 | metaViewportScalableFix.default(); 28 | } ); 29 | } 30 | 31 | if ( edacFrontendFixes?.remove_title_if_preferred_accessible_name?.enabled ) { 32 | // lazy import the module 33 | import( /* webpackChunkName: "remove-title-if-preferred-accessible-name" */ './Fixes/removeTitleIfPrefferedAccessibleNameFix' ).then( ( removeTitleIfPreferredAccessibleNameFix ) => { 34 | removeTitleIfPreferredAccessibleNameFix.default(); 35 | } ); 36 | } 37 | 38 | if ( edacFrontendFixes?.underline?.enabled ) { 39 | // lazy import the module 40 | import( /* webpackChunkName: "underline" */ './Fixes/underlineFix' ).then( ( underlineFix ) => { 41 | underlineFix.default(); 42 | } ); 43 | } 44 | 45 | if ( edacFrontendFixes?.meta_viewport_scalable?.enabled ) { 46 | // lazy import the module 47 | import( /* webpackChunkName: "meta-viewport-scalable" */ './Fixes/metaViewportScalableFix' ).then( ( metaViewportScalableFix ) => { 48 | metaViewportScalableFix.default(); 49 | } ); 50 | } 51 | 52 | if ( edacFrontendFixes?.prevent_links_opening_new_windows?.enabled ) { 53 | // lazy import the module 54 | import( /* webpackChunkName: "prevent-links-opening-in-new-window" */ './Fixes/preventLinksOpeningNewWindowFix' ).then( ( preventLinksOpeningNewWindowFix ) => { 55 | preventLinksOpeningNewWindowFix.default(); 56 | } ); 57 | } 58 | 59 | if ( edacFrontendFixes?.new_window_warning?.enabled ) { 60 | // lazy import the module 61 | import( /* webpackChunkName: "new-window-warning" */ './Fixes/newWindowWarning' ).then( ( newWindowWarning ) => { 62 | newWindowWarning.default(); 63 | } ); 64 | } 65 | -------------------------------------------------------------------------------- /src/frontendHighlighterApp/images/edac-emblem.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equalizedigital/accessibility-checker/7c52a7c53a6967dbf3d18f90e69b5a58a56f5f91/src/frontendHighlighterApp/images/edac-emblem.png -------------------------------------------------------------------------------- /src/frontendHighlighterApp/images/highlight-icon-error.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/frontendHighlighterApp/images/highlight-icon-ignored.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/frontendHighlighterApp/images/highlight-icon-warning.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/frontendHighlighterApp/images/ignore-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | 10 | 15 | 19 | 22 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/pageScanner/checks/always-fail.js: -------------------------------------------------------------------------------- 1 | export default { 2 | id: 'always-fail', 3 | metadata: { 4 | impact: 'critical', 5 | messages: { 6 | pass: 'This test passed.', 7 | fail: 'This test failed.', 8 | }, 9 | }, 10 | evaluate() { 11 | return false; 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /src/pageScanner/checks/anchor-exists.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Check if the anchor's target exists in the DOM. 3 | * 4 | * @param {Node} node The anchor node to evaluate. 5 | * @return {boolean} True if the target element exists, false otherwise. 6 | */ 7 | 8 | export default { 9 | id: 'anchor_exists', 10 | evaluate: ( node ) => { 11 | return document.querySelector( node.getAttribute( 'href' ) ) !== null; 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /src/pageScanner/checks/aria-describedby-not-found.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Check if the aria-describedby attribute references an existing element. 3 | * 4 | * @param {Node} node The node to evaluate. 5 | * @return {boolean} True if all references exist, false otherwise. 6 | */ 7 | import { checkAriaReferences } from '../utils/aria-utils'; 8 | 9 | export default { 10 | id: 'aria_describedby_not_found', 11 | evaluate: ( node ) => { 12 | return checkAriaReferences( node, 'aria-describedby' ); 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /src/pageScanner/checks/aria-hidden-valid-usage.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Check for valid use of aria-hidden="true" 3 | * 4 | * @param {Node} node The node to evaluate. 5 | * @return {boolean} True if the aria-hidden usage is valid, false otherwise. 6 | */ 7 | 8 | // Common screen reader text classes 9 | const srClasses = [ 10 | 'screen-reader-text', 'sr-only', 'show-for-sr', 'visuallyhidden', 11 | 'visually-hidden', 'hidden-visually', 'invisible', 12 | 'accessibly-hidden', 'hide', 'hidden', 13 | ]; 14 | 15 | export default { 16 | id: 'aria_hidden_valid_usage', 17 | evaluate: ( node ) => { 18 | // Check if element is hidden with CSS 19 | const computedStyle = window.getComputedStyle( node ); 20 | if ( computedStyle.display === 'none' || computedStyle.visibility === 'hidden' ) { 21 | return true; 22 | } 23 | 24 | // Check for valid element properties 25 | if ( node.classList.contains( 'wp-block-spacer' ) ) { 26 | return true; 27 | } 28 | 29 | const role = node.getAttribute( 'role' ); 30 | if ( role?.split( /\s+/ ).includes( 'presentation' ) ) { 31 | return true; 32 | } 33 | 34 | const parentNode = node.parentElement; 35 | if ( ! parentNode ) { 36 | return false; 37 | } 38 | 39 | // Check if parent is hidden with CSS 40 | const parentStyle = window.getComputedStyle( parentNode ); 41 | if ( parentStyle.display === 'none' || parentStyle.visibility === 'hidden' ) { 42 | return true; 43 | } 44 | 45 | // Check if parent is button/anchor with accessible content 46 | if ( [ 'button', 'a' ].includes( parentNode.tagName.toLowerCase() ) ) { 47 | // Parent has non-empty aria-label 48 | 49 | if ( 50 | ( 51 | parentNode.hasAttribute( 'aria-label' ) && 52 | parentNode.getAttribute( 'aria-label' ).trim() 53 | ) || 54 | ( 55 | parentNode.hasAttribute( 'aria-labelledby' ) && 56 | document.getElementById( parentNode.getAttribute( 'aria-labelledby' ) ) 57 | ) 58 | ) { 59 | return true; 60 | } 61 | 62 | // Check for visible text (excluding the aria-hidden element) 63 | for ( const child of parentNode.childNodes ) { 64 | if ( child !== node && 65 | child.nodeType === Node.TEXT_NODE && 66 | child.textContent.trim() ) { 67 | return true; 68 | } 69 | } 70 | } 71 | 72 | // Check siblings for screen reader text classes 73 | const siblings = Array.from( parentNode.children ); 74 | for ( const sibling of siblings ) { 75 | if ( sibling !== node ) { 76 | for ( const srClass of srClasses ) { 77 | if ( sibling.classList.contains( srClass ) || 78 | sibling.className.toLowerCase().includes( srClass ) ) { 79 | return true; 80 | } 81 | } 82 | } 83 | } 84 | 85 | return false; 86 | }, 87 | }; 88 | -------------------------------------------------------------------------------- /src/pageScanner/checks/aria-label-not-found.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Check if the aria-labelledby attribute references an existing element. 3 | * 4 | * @param {Node} node The node to evaluate. 5 | * @return {boolean} True if all references exist, false otherwise. 6 | */ 7 | import { checkAriaReferences } from '../utils/aria-utils'; 8 | 9 | export default { 10 | id: 'aria_label_not_found', 11 | evaluate: ( node ) => { 12 | return checkAriaReferences( node, 'aria-labelledby' ); 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /src/pageScanner/checks/aria-owns-not-found.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Check if the aria-owns attribute references an existing element. 3 | * 4 | * @param {Node} node The node to evaluate. 5 | * @return {boolean} True if all references exist, false otherwise. 6 | */ 7 | import { checkAriaReferences } from '../utils/aria-utils'; 8 | 9 | export default { 10 | id: 'aria_owns_not_found', 11 | evaluate: ( node ) => { 12 | return checkAriaReferences( node, 'aria-owns' ); 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /src/pageScanner/checks/element-is-u-tag.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Axe core check against nodes to determine if they are tags. 3 | * 4 | * @param {Node} node The node to evaluate. 5 | * @return {boolean} True if the node is a tag, false otherwise. 6 | */ 7 | 8 | export default { 9 | id: 'element_is_u_tag', 10 | evaluate: ( node ) => { 11 | return node.tagName.toLowerCase() === 'u'; 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /src/pageScanner/checks/element-with-underline.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Axe core check for elements with underlines. 3 | * 4 | * @param {Node} node The node to evaluate. 5 | * @return {boolean} True if the node has an underline in computed styles, false otherwise. 6 | */ 7 | 8 | export default { 9 | id: 'element_has_computed_underline', 10 | evaluate: ( node ) => { 11 | const style = window.getComputedStyle( node ); 12 | return style.textDecorationLine.includes( 'underline' ); 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /src/pageScanner/checks/has-ambiguous-text.js: -------------------------------------------------------------------------------- 1 | import { __ } from '@wordpress/i18n'; 2 | 3 | const ambiguousPhrases = [ 4 | __( 'click', 'accessibility-checker' ), 5 | __( 'click here', 'accessibility-checker' ), 6 | __( 'here', 'accessibility-checker' ), 7 | __( 'go here', 'accessibility-checker' ), 8 | __( 'more', 'accessibility-checker' ), 9 | __( 'more...', 'accessibility-checker' ), 10 | __( 'more…', 'accessibility-checker' ), 11 | __( 'details', 'accessibility-checker' ), 12 | __( 'more details', 'accessibility-checker' ), 13 | __( 'link', 'accessibility-checker' ), 14 | __( 'this page', 'accessibility-checker' ), 15 | __( 'continue', 'accessibility-checker' ), 16 | __( 'continue reading', 'accessibility-checker' ), 17 | __( 'read more', 'accessibility-checker' ), 18 | __( 'open', 'accessibility-checker' ), 19 | __( 'download', 'accessibility-checker' ), 20 | __( 'button', 'accessibility-checker' ), 21 | __( 'keep reading', 'accessibility-checker' ), 22 | __( 'learn more', 'accessibility-checker' ), 23 | __( 'opens a new window', 'accessibility-checker' ), 24 | ]; 25 | 26 | const checkAmbiguousPhrase = ( text ) => { 27 | if ( ! text ) { 28 | return false; 29 | } 30 | text = text.toLowerCase().replace( /[^a-z]+/g, ' ' ).trim(); 31 | return ambiguousPhrases.includes( text ); 32 | }; 33 | 34 | export default { 35 | id: 'has_ambiguous_text', 36 | evaluate: ( node ) => { 37 | if ( node.hasAttribute( 'aria-label' ) ) { 38 | const ariaLabel = node.getAttribute( 'aria-label' ); 39 | return checkAmbiguousPhrase( ariaLabel ); 40 | } 41 | 42 | if ( node.hasAttribute( 'aria-labelledby' ) ) { 43 | const label = node.getAttribute( 'aria-labelledby' ); 44 | const labelText = document.getElementById( label )?.textContent; 45 | return checkAmbiguousPhrase( labelText ); 46 | } 47 | 48 | if ( node.textContent && node.textContent !== '' ) { 49 | return checkAmbiguousPhrase( node.textContent ); 50 | } 51 | 52 | const images = node.querySelectorAll( 'img' ); 53 | for ( const image of images ) { 54 | const altText = image.getAttribute( 'alt' ); 55 | if ( checkAmbiguousPhrase( altText ) ) { 56 | return true; 57 | } 58 | } 59 | 60 | return false; 61 | }, 62 | }; 63 | -------------------------------------------------------------------------------- /src/pageScanner/checks/has-subheadings-if-long-content.js: -------------------------------------------------------------------------------- 1 | const LONG_CONTENT_THRESHOLD = 400; 2 | 3 | export default { 4 | id: 'has_subheadings_if_long_content', 5 | evaluate: ( node ) => { 6 | if ( node !== document.body ) { 7 | return true; 8 | } 9 | 10 | // Count words in text content (excluding scripts, styles) 11 | const text = node.textContent.replace( /\s+/g, ' ' ).trim(); 12 | const wordCount = text.split( /\s+/ ).length; 13 | 14 | if ( wordCount < LONG_CONTENT_THRESHOLD ) { 15 | return true; 16 | } 17 | 18 | // Find all headings (both HTML and ARIA) 19 | const headings = [ 20 | 'h2, [role="heading"][aria-level="2"]', 21 | 'h3, [role="heading"][aria-level="3"]', 22 | 'h4, [role="heading"][aria-level="4"]', 23 | 'h5, [role="heading"][aria-level="5"]', 24 | 'h6, [role="heading"][aria-level="6"]', 25 | ].map( ( selector ) => document.querySelectorAll( selector ).length ) 26 | .reduce( ( sum, count ) => sum + count, 0 ); 27 | 28 | return headings > 0; 29 | }, 30 | }; 31 | -------------------------------------------------------------------------------- /src/pageScanner/checks/has-text-docoration.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equalizedigital/accessibility-checker/7c52a7c53a6967dbf3d18f90e69b5a58a56f5f91/src/pageScanner/checks/has-text-docoration.js -------------------------------------------------------------------------------- /src/pageScanner/checks/heading-is-empty.js: -------------------------------------------------------------------------------- 1 | export default { 2 | id: 'heading_is_empty', 3 | evaluate( node ) { 4 | // Get all aria-hidden elements 5 | const hiddenElements = node.querySelectorAll( '[aria-hidden="true"]' ); 6 | 7 | // Clone node to work with 8 | const clone = node.cloneNode( true ); 9 | 10 | // Remove aria-hidden elements from clone 11 | hiddenElements.forEach( ( el ) => { 12 | const elementToRemove = Array.from( clone.querySelectorAll( '*' ) ).find( ( cloneEl ) => 13 | // Find the corresponding element in the clone by comparing content and structure 14 | cloneEl.isEqualNode( el ) 15 | ); 16 | if ( elementToRemove ) { 17 | elementToRemove.remove(); 18 | } 19 | } ); 20 | 21 | // Check for visible text content (excluding just whitespace, hyphens, underscores) 22 | const headingText = clone.textContent.trim(); 23 | const hasValidText = headingText && ! /^[-_\s]*$/.test( headingText ); 24 | 25 | // Check for aria-label 26 | const ariaLabel = node.getAttribute( 'aria-label' ); 27 | const hasAriaLabel = ariaLabel && ariaLabel.trim() !== ''; 28 | 29 | // Check for images with alt text 30 | const images = node.querySelectorAll( 'img' ); 31 | let hasImageWithAlt = false; 32 | for ( let i = 0; i < images.length; i++ ) { 33 | const alt = images[ i ].getAttribute( 'alt' ); 34 | if ( alt && alt.trim() !== '' ) { 35 | hasImageWithAlt = true; 36 | break; 37 | } 38 | } 39 | 40 | // Check for SVG with title or aria-label 41 | const svgs = node.querySelectorAll( 'svg' ); 42 | let hasSvgWithAccessibleText = false; 43 | for ( let i = 0; i < svgs.length; i++ ) { 44 | const title = svgs[ i ].querySelector( 'title' ); 45 | if ( title && title.textContent.trim() !== '' ) { 46 | hasSvgWithAccessibleText = true; 47 | break; 48 | } 49 | // Also check for aria-label on SVG 50 | const svgAriaLabel = svgs[ i ].getAttribute( 'aria-label' ); 51 | if ( svgAriaLabel && svgAriaLabel.trim() !== '' ) { 52 | hasSvgWithAccessibleText = true; 53 | break; 54 | } 55 | } 56 | 57 | // Check aria-labelledby 58 | const ariaLabelledby = node.getAttribute( 'aria-labelledby' ); 59 | let hasAriaLabelledby = false; 60 | if ( ariaLabelledby ) { 61 | const ids = ariaLabelledby.split( /\s+/ ); 62 | for ( let i = 0; i < ids.length; i++ ) { 63 | const labelElement = document.getElementById( ids[ i ] ); 64 | if ( labelElement && labelElement.textContent.trim() !== '' ) { 65 | hasAriaLabelledby = true; 66 | break; 67 | } 68 | } 69 | } 70 | 71 | return hasValidText || hasAriaLabel || hasImageWithAlt || hasSvgWithAccessibleText || hasAriaLabelledby; 72 | }, 73 | }; 74 | -------------------------------------------------------------------------------- /src/pageScanner/checks/image-input-has-alt.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Axe core check against nodes to determine if they are tags. 3 | * 4 | * @param {Node} node The node to evaluate. 5 | * @return {boolean} True if the node is a tag, false otherwise. 6 | */ 7 | 8 | export default { 9 | id: 'image_input_has_alt', 10 | evaluate: ( node ) => { 11 | // Not an image input, skip. 12 | if ( node.tagName.toLowerCase() === 'input' && node.type !== 'image' ) { 13 | return false; 14 | } 15 | 16 | // Non empty alt attribute. 17 | if ( node.getAttribute( 'alt' )?.trim() !== '' ) { 18 | return true; 19 | } 20 | 21 | return false; 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /src/pageScanner/checks/img-alt-long-check.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Check if image alt text exceeds the maximum allowed length. 3 | * Ported from PHP function edac_rule_img_alt_long 4 | */ 5 | 6 | export default { 7 | id: 'img_alt_long_check', 8 | evaluate( node, options = {} ) { 9 | // Get alt text from the node 10 | const altText = node.getAttribute( 'alt' ); 11 | 12 | // If alt text exists and is longer than max length, it fails the check 13 | if ( altText && altText.length > options.maxAltLength ) { 14 | return false; 15 | } 16 | 17 | // Otherwise it passes 18 | return true; 19 | }, 20 | options: { 21 | maxAltLength: 300, 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /src/pageScanner/checks/img-alt-redundant-check.js: -------------------------------------------------------------------------------- 1 | import { normalizeText } from '../helpers/helpers'; 2 | 3 | // Global cache for alt text mapping 4 | export const altTextMap = new Map(); 5 | 6 | /** 7 | * Initialize the alt text map if it's empty. 8 | * Maps normalized alt text to arrays of images with that text. 9 | */ 10 | export function initializeAltTextMap() { 11 | if ( altTextMap.size === 0 ) { 12 | const allImages = document.querySelectorAll( 'img' ); 13 | allImages.forEach( ( img ) => { 14 | const imgAlt = normalizeText( img.getAttribute( 'alt' ) ); 15 | if ( imgAlt ) { 16 | if ( ! altTextMap.has( imgAlt ) ) { 17 | altTextMap.set( imgAlt, [] ); 18 | } 19 | altTextMap.get( imgAlt ).push( img ); 20 | } 21 | } ); 22 | } 23 | } 24 | 25 | /** 26 | * Check if an image's alternative text is redundant. 27 | * Returns false if redundancy is detected. 28 | */ 29 | export default { 30 | id: 'img_alt_redundant_check', 31 | evaluate( node ) { 32 | // Get the alt text (normalized) 33 | const alt = normalizeText( node.getAttribute( 'alt' ) ); 34 | if ( ! alt ) { 35 | // If there is no alt, this check doesn't apply. 36 | return true; 37 | } 38 | 39 | // Check if alt text matches title attribute 40 | const title = normalizeText( node.getAttribute( 'title' ) ); 41 | if ( title && alt === title ) { 42 | return false; 43 | } 44 | 45 | // Check image inside a link whose text equals alt text 46 | const parentLink = node.closest( 'a' ); 47 | if ( parentLink ) { 48 | // Get visible text of the anchor. 49 | const linkText = normalizeText( parentLink.textContent ); 50 | if ( linkText && alt === linkText ) { 51 | return false; 52 | } 53 | } 54 | 55 | // Check image inside a figure with figcaption matching alt text 56 | const figure = node.closest( 'figure' ); 57 | if ( figure ) { 58 | const figcaption = figure.querySelector( 'figcaption' ); 59 | if ( figcaption ) { 60 | const captionText = normalizeText( figcaption.textContent ); 61 | if ( captionText && alt === captionText ) { 62 | return false; 63 | } 64 | } 65 | } 66 | 67 | // Make sure the alt text map is available and filled. 68 | initializeAltTextMap(); 69 | 70 | // Check if current alt text appears in multiple images 71 | if ( altTextMap.has( alt ) && altTextMap.get( alt ).length > 1 ) { 72 | return false; 73 | } 74 | 75 | // If no redundant text found, pass. 76 | return true; 77 | }, 78 | options: {}, 79 | metadata: { 80 | impact: 'warning', 81 | messages: { 82 | pass: 'Image alternative text is not redundant.', 83 | fail: 'Image alternative text is redundant (matches title, link text, caption, or is duplicated).', 84 | }, 85 | }, 86 | }; 87 | -------------------------------------------------------------------------------- /src/pageScanner/checks/link-has-valid-href-or-role.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Check if a link is improperly used (missing href or href="#", and not a button). 3 | * 4 | * @param {Node} node The node to evaluate. 5 | * @return {boolean} True if the link is valid or semantically correct, false otherwise. 6 | */ 7 | 8 | export default { 9 | id: 'link_has_valid_href_or_role', 10 | evaluate: ( node ) => { 11 | if ( node.nodeName.toLowerCase() !== 'a' ) { 12 | return true; 13 | } 14 | 15 | const href = node.getAttribute( 'href' ); 16 | const role = node.getAttribute( 'role' ) || ''; 17 | 18 | // Allow if it's a button role 19 | if ( role.toLowerCase().split( /\s+/ ).includes( 'button' ) ) { 20 | return true; 21 | } 22 | 23 | const trimmedHref = href ? href.trim() : ''; 24 | 25 | // Fail if href is missing, just '#', or contains invalid protocols 26 | if ( ! href || 27 | trimmedHref === '#' || 28 | href.toLowerCase().startsWith( 'javascript:' ) || 29 | href.toLowerCase().startsWith( 'data:' ) || 30 | href.toLowerCase().startsWith( 'file:' ) 31 | ) { 32 | return false; 33 | } 34 | 35 | // Optionally validate URL format if it's an absolute URL 36 | if ( href.includes( '://' ) ) { 37 | try { 38 | new URL( href ); 39 | } catch ( e ) { 40 | return false; // Invalid URL formats 41 | } 42 | } 43 | 44 | return true; 45 | }, 46 | }; 47 | -------------------------------------------------------------------------------- /src/pageScanner/checks/link-points-to-html.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Check to detect if an anchor tag links to a non-HTML document type. 3 | * 4 | * @param {Node} node The node to evaluate. 5 | * @return {boolean} True if the link is not a problematic file type, false if it should be flagged. 6 | */ 7 | 8 | export const nonHtmlExtensions = [ 9 | 'rtf', 'wpd', 'ods', 'odt', 'odp', 'sxw', 'sxc', 'sxd', 'sxi', 'pages', 'key', 10 | ]; 11 | 12 | export default { 13 | id: 'link_points_to_html', 14 | evaluate: ( node ) => { 15 | if ( node.nodeName.toLowerCase() !== 'a' ) { 16 | return true; 17 | } 18 | 19 | const href = node.getAttribute( 'href' ) || ''; 20 | 21 | try { 22 | const url = new URL( href, document.baseURI ); 23 | const pathParts = url.pathname.split( '.' ); 24 | const extension = pathParts.length > 1 ? pathParts.pop().toLowerCase() : ''; 25 | 26 | if ( nonHtmlExtensions.includes( extension ) ) { 27 | return false; // Fail: link points to a non-HTML document type 28 | } 29 | } catch { 30 | return true; // Pass: link does not have a valid URL 31 | } 32 | 33 | return true; // Pass: link points to an HTML document type 34 | }, 35 | }; 36 | -------------------------------------------------------------------------------- /src/pageScanner/checks/link-target-blank-without-informing.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Axe core check against nodes to determine if link opens in new tab and if so has appropriate aria-label or aria-labelledby. 3 | */ 4 | 5 | import { __ } from '@wordpress/i18n'; 6 | 7 | const allowedPhrases = [ 8 | __( 'new window', 'accessibility-checker' ), 9 | __( 'new tab', 'accessibility-checker' ), 10 | ]; 11 | 12 | /** 13 | * Check for links that open in a new tab without informing the user. 14 | * 15 | * @param {HTMLElement} node The node to evaluate. 16 | * return {boolean} False if the node is a link that opens in a new tab without informing the user, true if informed. 17 | */ 18 | export default { 19 | id: 'link_target_blank_without_informing', 20 | evaluate: ( node ) => { 21 | // Bail early if not a link or not target blank. 22 | if ( node.tagName.toLowerCase() !== 'a' || node.getAttribute( 'target' ) !== '_blank' ) { 23 | return false; 24 | } 25 | 26 | // Check plain text. 27 | if ( checkTextHasInfoCallout( node.textContent ) ) { 28 | return false; 29 | } 30 | 31 | // Check aria-label. 32 | if ( node.hasAttribute( 'aria-label' ) && checkTextHasInfoCallout( node.getAttribute( 'aria-label' ) ) ) { 33 | return false; 34 | } 35 | 36 | // Check aria-labelledby. 37 | if ( node.hasAttribute( 'aria-labelledby' ) ) { 38 | const labelElement = document.getElementById( node.getAttribute( 'aria-labelledby' ) ); 39 | if ( labelElement && checkTextHasInfoCallout( labelElement.textContent ) ) { 40 | return false; 41 | } 42 | } 43 | 44 | // Check image alt text. 45 | const images = node.querySelectorAll( 'img' ); 46 | for ( const image of images ) { 47 | if ( checkTextHasInfoCallout( image.getAttribute( 'alt' ) ) ) { 48 | return false; 49 | } 50 | } 51 | 52 | // Nothing so far has indicated that this is a new window/tab opener so this is a fail. 53 | return true; 54 | }, 55 | }; 56 | 57 | /** 58 | * Checks that some text contains a phrase that indicates a new window or tab opener. 59 | * 60 | * @param {string} text The text to check. 61 | * @return {boolean} True if the text contains a new window/tab phrase, false otherwise. 62 | */ 63 | const checkTextHasInfoCallout = ( text ) => { 64 | if ( ! text ) { 65 | return false; 66 | } 67 | return allowedPhrases.some( ( phrase ) => text.toLowerCase().includes( phrase ) ); 68 | }; 69 | -------------------------------------------------------------------------------- /src/pageScanner/checks/linked-image-alt-not-empty.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Check that linked images have non-empty alt text. 3 | * 4 | * This check evaluates whether images inside anchor tags have meaningful alternative text. 5 | * It fails if an image has an empty or whitespace-only alt attribute, unless: 6 | * - The link itself has sufficient descriptive text 7 | * - The image is hidden or decorative (role="presentation" or aria-hidden="true") 8 | * - The anchor has aria-label or title attributes 9 | */ 10 | 11 | import { getVisibleImages, hasAccessibleText } from '../helpers/linkedImageUtils.js'; 12 | 13 | export default { 14 | id: 'linked_image_alt_not_empty', 15 | evaluate: ( node ) => { 16 | if ( node.nodeName.toLowerCase() !== 'a' ) { 17 | return true; 18 | } 19 | 20 | if ( hasAccessibleText( node ) ) { 21 | return true; 22 | } 23 | 24 | const images = getVisibleImages( node ); 25 | if ( images.length === 0 ) { 26 | return true; 27 | } 28 | 29 | // Check each visible image for non-empty alt text 30 | return images.every( ( img ) => { 31 | const alt = img.getAttribute( 'alt' ); 32 | const role = img.getAttribute( 'role' ); 33 | const ariaHidden = img.getAttribute( 'aria-hidden' ); 34 | 35 | if ( role === 'presentation' || ariaHidden === 'true' ) { 36 | return true; 37 | } 38 | 39 | // Check for null, empty string, or whitespace-only alt text 40 | return alt !== null && alt.trim() !== ''; 41 | } ); 42 | }, 43 | }; 44 | -------------------------------------------------------------------------------- /src/pageScanner/checks/linked-image-alt-present.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Check if the given node is an anchor tag () and validate its content. 3 | * 4 | * This function evaluates whether an anchor tag contains sufficient descriptive 5 | * content, such as text, aria-label, title, or valid alt attributes for images. 6 | * 7 | * @param {Node} node The node to evaluate. 8 | * @return {boolean} True if the anchor tag has valid descriptive content, false otherwise. 9 | */ 10 | 11 | import { getVisibleImages, hasAccessibleText } from '../helpers/linkedImageUtils.js'; 12 | 13 | export default { 14 | id: 'linked_image_alt_present', 15 | evaluate: ( node ) => { 16 | if ( node.nodeName.toLowerCase() !== 'a' ) { 17 | return true; 18 | } 19 | 20 | if ( hasAccessibleText( node ) ) { 21 | return true; 22 | } 23 | 24 | const images = getVisibleImages( node ); 25 | if ( images.length === 0 ) { 26 | return true; 27 | } 28 | 29 | // Check each visible image for alt attribute 30 | return images.every( ( img ) => { 31 | const hasAlt = img.hasAttribute( 'alt' ); 32 | const role = img.getAttribute( 'role' ); 33 | const ariaHidden = img.getAttribute( 'aria-hidden' ); 34 | 35 | return hasAlt || role === 'presentation' || ariaHidden === 'true'; 36 | } ); 37 | }, 38 | }; 39 | -------------------------------------------------------------------------------- /src/pageScanner/checks/longdesc-valid.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Check if the provided node is an tag and validate its longdesc attribute. 3 | * 4 | * @param {Node} node The node to evaluate. 5 | * @return {boolean} True if the longdesc attribute is valid or not applicable, false otherwise. 6 | */ 7 | 8 | const imageExtensions = [ 9 | 'apng', 'bmp', 'gif', 'ico', 'cur', 'jpg', 'jpeg', 'jfif', 10 | 'pjpeg', 'pjp', 'png', 'svg', 'tif', 'tiff', 'webp', 11 | ]; 12 | 13 | export default { 14 | id: 'longdesc_valid', 15 | evaluate: ( node ) => { 16 | if ( node.nodeName.toLowerCase() !== 'img' ) { 17 | return true; 18 | } 19 | 20 | const longdesc = node.getAttribute( 'longdesc' ); 21 | if ( longdesc === null ) { 22 | return true; 23 | } 24 | 25 | if ( longdesc.trim() === '' ) { 26 | return false; 27 | } 28 | 29 | // Reject malformed protocols like "ht!tp://" 30 | if ( longdesc.includes( ':' ) ) { 31 | // This regex matches valid protocols per RFC 3986: e.g., "http://", "https://", "ftp://" 32 | const protocolPattern = /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//; 33 | if ( ! protocolPattern.test( longdesc ) ) { 34 | return false; 35 | } 36 | } 37 | 38 | let url; 39 | try { 40 | url = new URL( longdesc, document.baseURI ); 41 | } catch { 42 | return false; // Malformed URL (even relative) 43 | } 44 | 45 | const pathname = url.pathname; 46 | // Handle path ending with slash 47 | const filename = pathname.endsWith( '/' ) ? '' : pathname.split( '/' ).pop(); 48 | 49 | // Only check extension if there's a filename 50 | if ( ! filename ) { 51 | return false; 52 | } 53 | 54 | // Extract extension, accounting for filenames with multiple dots 55 | // and query parameters or fragments 56 | const extMatch = filename.match( /\.([^.?#]+)(?:\?.*)?(?:#.*)?$/ ); 57 | const ext = extMatch ? extMatch[ 1 ].toLowerCase() : ''; 58 | 59 | if ( imageExtensions.includes( ext ) ) { 60 | return false; 61 | } 62 | 63 | return true; 64 | }, 65 | }; 66 | -------------------------------------------------------------------------------- /src/pageScanner/checks/paragraph-not-empty.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Check that a paragraph is NOT empty. 3 | * 4 | * @param {Node} node The paragraph element to evaluate. 5 | * @return {boolean} Returns true if the paragraph has content, false if empty. 6 | */ 7 | 8 | export default { 9 | id: 'paragraph_not_empty', 10 | evaluate: ( node ) => { 11 | if ( 'p' !== node.tagName.toLowerCase() ) { 12 | return true; 13 | } 14 | 15 | // If element has aria-hidden attribute with a value of true, it passes. 16 | if ( node.getAttribute( 'aria-hidden' ) && node.getAttribute( 'aria-hidden' ).toLowerCase() === 'true' ) { 17 | return true; 18 | } 19 | 20 | // Pass if there are child nodes and any child nodes are not text nodes (not Type of 3). 21 | if ( node.childNodes.length && Array.from( node.childNodes ).some( ( child ) => child.nodeType !== 3 ) ) { 22 | return true; 23 | } 24 | 25 | // If there is text content then it passes. 26 | return node.textContent.trim() !== ''; 27 | }, 28 | }; 29 | -------------------------------------------------------------------------------- /src/pageScanner/checks/paragraph-styled-as-header.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Check for elements with text content that is styled like a header. 3 | * 4 | * Axe-core has a built-in `p-as-heading` rule that checks for paragraphs 5 | * that are styled like headings. That rule is less robust than this check, 6 | * as it only checks for paragraphs with bold or italic text and gives back 7 | * 'incomplete' or uncertain results for some of the test data that we want 8 | * to flag for further checks. 9 | * 10 | * This check in this file takes into account the font size, length of the 11 | * text, and considers paragraphs with large font size as headers as well. 12 | * 13 | * @param {Node} node The node to evaluate. 14 | * @return {boolean} True if the node is styled like a header, false otherwise. Paragraphs with only bold or italic, 15 | * are shorter than 50 characters, or are short with large font size are considered headers. 16 | */ 17 | 18 | import { fontSizeInPx } from '../helpers/helpers.js'; 19 | 20 | export default { 21 | id: 'paragraph_styled_as_header', 22 | evaluate: ( node ) => { 23 | const pixelSize = fontSizeInPx( node ); 24 | 25 | // If there's no text content, it's not a header. 26 | if ( ! node.textContent.trim() ) { 27 | return false; 28 | } 29 | 30 | // Long paragraphs or those with font size under 16px are unlikely to be headers. 31 | if ( node.textContent.trim().length > 50 || pixelSize < 16 ) { 32 | return false; 33 | } 34 | 35 | // Paragraphs that are 20px or more are probably headers. 36 | if ( pixelSize >= 20 ) { 37 | return true; 38 | } 39 | 40 | const style = window.getComputedStyle( node ); 41 | 42 | const fontWeight = style.getPropertyValue( 'font-weight' ); 43 | const isBold = [ 'bold', 'bolder', '700', '800', '900' ].includes( fontWeight ); 44 | 45 | const fontStyle = style.getPropertyValue( 'font-style' ); 46 | const isItalic = [ 'italic', 'oblique' ].includes( fontStyle ); 47 | 48 | let wrappedByBoldOrItalicTag = false; 49 | const boldOrItalicTags = node.querySelectorAll( 'b, strong, i, em' ); 50 | boldOrItalicTags.forEach( ( tag ) => { 51 | if ( tag.textContent === node.textContent ) { 52 | wrappedByBoldOrItalicTag = true; 53 | } 54 | } ); 55 | 56 | if ( isBold || isItalic || wrappedByBoldOrItalicTag ) { 57 | return true; 58 | } 59 | 60 | // Didn't find anything indicating this is a possible header. 61 | return false; 62 | }, 63 | }; 64 | -------------------------------------------------------------------------------- /src/pageScanner/checks/slider-detected.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Rule to detect the presence of slider components that may require accessibility features. 3 | * This rule identifies various slider elements based on class names or specific attributes 4 | * that are commonly associated with slider implementations. 5 | */ 6 | 7 | const sliderClassKeywords = [ 8 | 'slider', 9 | 'carousel', 10 | 'owl-carousel', 11 | 'soliloquy-container', 12 | 'n2-section-smartslider', 13 | 'metaslider', 14 | 'master-slider', 15 | 'rev_slider', 16 | 'royalSlider', 17 | 'wonderpluginslider', 18 | 'meteor-slides', 19 | 'flexslider', 20 | 'slick-slider', 21 | 'uagb-slick-carousel', 22 | 'swiper-container', 23 | 'flickity-slider', 24 | 'spacegallery', 25 | 'blueimp-gallery', 26 | 'seq-active', 27 | 'siema', 28 | 'keen-slider', 29 | 'bxslider', 30 | 'bx-wrapper', 31 | 'glide--slider', 32 | ]; 33 | 34 | const sliderDetected = { 35 | id: 'slider_detected', 36 | evaluate: ( node ) => { 37 | const className = node.getAttribute( 'class' ) || ''; 38 | const classTokens = className.toLowerCase().split( /\s+/ ); 39 | const matchesClass = sliderClassKeywords.some( ( keyword ) => classTokens.includes( keyword ) ); 40 | 41 | const hasDataAttr = node.hasAttribute( 'data-jssor-slider' ) || node.hasAttribute( 'data-layerslider-uid' ); 42 | 43 | if ( matchesClass || hasDataAttr ) { 44 | return false; // Fail check → trigger violation 45 | } 46 | 47 | return true; 48 | }, 49 | }; 50 | export { sliderClassKeywords }; 51 | export default sliderDetected; 52 | -------------------------------------------------------------------------------- /src/pageScanner/checks/table-has-headers.js: -------------------------------------------------------------------------------- 1 | export default { 2 | id: 'table_has_headers', 3 | evaluate: ( node ) => { 4 | if ( node.nodeName.toLowerCase() !== 'table' ) { 5 | return true; 6 | } 7 | 8 | // TODO: Improve logic to account for colspan, rowspan, and complex ARIA header relationships 9 | 10 | const rows = Array.from( node.querySelectorAll( 'tr' ) ); 11 | 12 | if ( rows.length === 0 ) { 13 | return true; 14 | } 15 | 16 | // Case 1: Valid row-header table (every row starts with ) 17 | const isRowHeaderTable = rows.every( ( row ) => { 18 | const firstCell = row.children[ 0 ]; 19 | if ( ! firstCell || firstCell.tagName.toLowerCase() !== 'th' ) { 20 | return false; 21 | } 22 | 23 | const scope = firstCell.getAttribute( 'scope' ); 24 | return scope === 'row' || ! scope; 25 | } ); 26 | 27 | if ( isRowHeaderTable ) { 28 | return true; 29 | } 30 | 31 | // Case 2: Classic table with header row 32 | const headerRow = 33 | node.querySelector( 'thead tr' ) || 34 | rows.find( ( row ) => row.querySelectorAll( 'th' ).length > 0 ); 35 | 36 | if ( ! headerRow ) { 37 | return false; 38 | } 39 | 40 | const thCount = headerRow.querySelectorAll( 'th' ).length; 41 | if ( thCount === 0 ) { 42 | return false; 43 | } 44 | 45 | let headerRowEncountered = false; 46 | 47 | for ( const row of rows ) { 48 | if ( ! headerRowEncountered && row === headerRow ) { 49 | headerRowEncountered = true; 50 | continue; 51 | } 52 | 53 | const tdCount = row.querySelectorAll( 'td' ).length; 54 | if ( tdCount > thCount ) { 55 | return false; 56 | } 57 | } 58 | 59 | return true; 60 | }, 61 | }; 62 | -------------------------------------------------------------------------------- /src/pageScanner/checks/table-header-is-empty.js: -------------------------------------------------------------------------------- 1 | export default { 2 | id: 'table_header_is_empty', 3 | evaluate( node ) { 4 | // Check if the header has aria-hidden="true" 5 | if ( node.hasAttribute( 'aria-hidden' ) && node.getAttribute( 'aria-hidden' ) === 'true' ) { 6 | return true; 7 | } 8 | 9 | // Check if the table header has text content after stripping spaces,  , hyphens, emdashes, underscores 10 | const textContent = node.textContent.replace( /[\s\u00A0\-—_]/g, '' ); 11 | if ( textContent !== '' ) { 12 | return false; 13 | } 14 | 15 | // Check for aria-label or title on the table header itself 16 | if ( node.hasAttribute( 'aria-label' ) && node.getAttribute( 'aria-label' )?.trim() !== '' ) { 17 | return false; 18 | } 19 | 20 | if ( node.hasAttribute( 'title' ) && node.getAttribute( 'title' )?.trim() !== '' ) { 21 | return false; 22 | } 23 | 24 | // Check aria-labelledby 25 | if ( node.hasAttribute( 'aria-labelledby' ) ) { 26 | const labelledbyIds = node.getAttribute( 'aria-labelledby' )?.split( ' ' ); 27 | const labelledbyElements = labelledbyIds?.map( ( id ) => document.getElementById( id ) ).filter( Boolean ); 28 | if ( labelledbyElements.some( ( el ) => el.textContent?.trim() !== '' ) ) { 29 | return false; 30 | } 31 | } 32 | 33 | // Check for images with alt text 34 | const images = node.querySelectorAll( 'img' ); 35 | for ( const img of images ) { 36 | if ( img.hasAttribute( 'alt' ) && img.getAttribute( 'alt' )?.trim() !== '' ) { 37 | return false; 38 | } 39 | } 40 | 41 | // Check for icons with title or aria-label 42 | const icons = node.querySelectorAll( 'i' ); 43 | for ( const icon of icons ) { 44 | if ( 45 | ( icon.hasAttribute( 'title' ) && icon.getAttribute( 'title' )?.trim() !== '' ) || 46 | ( icon.hasAttribute( 'aria-label' ) && icon.getAttribute( 'aria-label' )?.trim() !== '' ) 47 | ) { 48 | return false; 49 | } 50 | } 51 | 52 | // Check for SVGs with title 53 | const svgs = node.querySelectorAll( 'svg' ); 54 | for ( const svg of svgs ) { 55 | if ( svg.querySelector( 'title' ) ) { 56 | return false; 57 | } 58 | } 59 | 60 | // If we've reached here, the table header is empty 61 | return true; 62 | }, 63 | }; 64 | -------------------------------------------------------------------------------- /src/pageScanner/checks/text-is-justified.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Axe core check for elements that are aligned to justify. 3 | * 4 | * @param {Node} node The node to evaluate. 5 | * @return {boolean} True if the node is justified, false otherwise. 6 | */ 7 | 8 | export default { 9 | id: 'text_is_justified', 10 | evaluate: ( node ) => { 11 | const style = window.getComputedStyle( node ); 12 | return style.textAlign.toLowerCase() === 'justify'; 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /src/pageScanner/checks/text-size-too-small.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Axe core check against nodes to determine if the text size is too small. 3 | * 4 | * @param {Node} node The node to evaluate. 5 | * @return {boolean} True if the text size is equal to or below the minimum size. False otherwise. 6 | */ 7 | 8 | import { fontSizeInPx } from '../helpers/helpers'; 9 | 10 | const SMALL_FONT_SIZE_THRESHOLD = 10; 11 | 12 | export default { 13 | id: 'text_size_too_small', 14 | evaluate: ( node ) => { 15 | // If the node has no text content then it can't have text that's too small. 16 | if ( ! node.textContent.trim().length ) { 17 | return false; 18 | } 19 | 20 | // Check if the node has any direct text nodes as children. For a node with no 21 | // children, or with TEXT_NODE children, evaluate the nodes font size. This 22 | // handles both leaf nodes and container elements with mixed content. 23 | const hasTextChild = Array.from( node.childNodes ).some( ( child ) => child.nodeType === Node.TEXT_NODE ); 24 | if ( ! node.childNodes.length || hasTextChild ) { 25 | return fontSizeInPx( node ) <= SMALL_FONT_SIZE_THRESHOLD; 26 | } 27 | 28 | // No text nodes were found in direct children, and this is not a leaf node, 29 | // so we can safely ignore font size checks. 30 | return false; 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /src/pageScanner/config/exclusions.js: -------------------------------------------------------------------------------- 1 | 2 | // Define the list of exclusions for accessibility scans. 3 | export const exclusionsArray = [ 4 | '#wpadminbar', 5 | '.edac-panel-container', 6 | '#query-monitor-main', 7 | '#qm-icon-container', 8 | ]; 9 | -------------------------------------------------------------------------------- /src/pageScanner/helpers/density.js: -------------------------------------------------------------------------------- 1 | import { exclusionsArray } from '../config/exclusions'; 2 | 3 | /** 4 | * Calculate page density metrics 5 | * @param {HTMLElement} body - The body element to analyze 6 | * @return {[number, number]} Array containing [elementCount, contentLength] 7 | */ 8 | export function getPageDensity( body = document.body ) { 9 | // If we can't get a body then return as if there was nothing. 10 | if ( ! body ) { 11 | return [ 0, 0 ]; 12 | } 13 | 14 | // Remove elements we don't want to count from a clone to avoid modifying original 15 | const bodyClone = body.cloneNode( true ); 16 | 17 | // Remove elements we don't want to count 18 | const selectorsToRemove = [ ...exclusionsArray, 'style', 'script' ]; 19 | selectorsToRemove.forEach( ( selector ) => { 20 | bodyClone.querySelectorAll( selector ).forEach( ( el ) => el.remove() ); 21 | } ); 22 | 23 | // Count all elements within the clone 24 | const allElements = bodyClone.getElementsByTagName( '*' ); 25 | const elementCount = allElements.length; 26 | 27 | // Get text content and count alphanumeric characters only 28 | const textContent = bodyClone.textContent || ''; 29 | const contentTextLength = textContent.replace( /[^A-Za-z0-9]/g, '' ).length; 30 | 31 | return [ elementCount, contentTextLength ]; 32 | } 33 | -------------------------------------------------------------------------------- /src/pageScanner/helpers/helpers.js: -------------------------------------------------------------------------------- 1 | export const fontSizeInPx = ( node ) => { 2 | if ( ! node || node.nodeType !== Node.ELEMENT_NODE ) { 3 | return 0; 4 | } 5 | 6 | const fontSize = parseFloat( window.getComputedStyle( node ).fontSize ); 7 | return typeof fontSize === 'number' ? fontSize : 0; 8 | }; 9 | 10 | /** 11 | * Helper function to normalize text by trimming and replacing consecutive whitespace 12 | * @param {string} text - Text to normalize 13 | * @return {string} Normalized text 14 | */ 15 | export const normalizeText = ( text ) => { 16 | return ( text || '' ).trim().toLowerCase().replace( /\s+/g, ' ' ); 17 | }; 18 | 19 | /** 20 | * Check if an element is visibly hidden via CSS or aria attributes 21 | * @param {HTMLElement} element The element to check 22 | * @return {boolean} True if element is hidden 23 | */ 24 | export const isVisiblyHidden = ( element ) => { 25 | const style = window.getComputedStyle( element ); 26 | return style.display === 'none' || 27 | style.visibility === 'hidden' || 28 | element.closest( '[aria-hidden="true"]' ) !== null; 29 | }; 30 | 31 | /** 32 | * Check if an element is visible in the DOM 33 | * 34 | * This is a recursive function so could be inefficient for deeply nested elements. 35 | * 36 | * @param {element} element an element to check for visibility. 37 | * @return {boolean|boolean|*} A boolean indicating if the element is visible or not. 38 | */ 39 | export const isElementVisible = ( element ) => { 40 | // A non-existent element can never be visible. 41 | if ( ! element ) { 42 | return false; 43 | } 44 | 45 | // Check if the element itself is hidden. 46 | const style = window.getComputedStyle( element ); 47 | if ( style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0' ) { 48 | return false; 49 | } 50 | 51 | // If there is a parent then check it recursively till there is no more parents. 52 | return element.parentElement ? isElementVisible( element.parentElement ) : true; 53 | }; 54 | 55 | /** 56 | * A Map that normalizes all keys to lowercase 57 | */ 58 | export class NormalizedMap extends Map { 59 | set( key, value ) { 60 | return super.set( typeof key === 'string' ? key.toLowerCase() : key, value ); 61 | } 62 | 63 | get( key ) { 64 | return super.get( typeof key === 'string' ? key.toLowerCase() : key ); 65 | } 66 | 67 | has( key ) { 68 | return super.has( typeof key === 'string' ? key.toLowerCase() : key ); 69 | } 70 | 71 | delete( key ) { 72 | return super.delete( typeof key === 'string' ? key.toLowerCase() : key ); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/pageScanner/helpers/linkedImageUtils.js: -------------------------------------------------------------------------------- 1 | import { isVisiblyHidden } from './helpers.js'; 2 | 3 | /** 4 | * Get visible images from an anchor element 5 | * @param {HTMLElement} node Anchor element to check 6 | * @return {Array} Array of visible image elements 7 | */ 8 | export const getVisibleImages = ( node ) => { 9 | const allImages = node.querySelectorAll( 'img' ); 10 | return Array.from( allImages ).filter( ( img ) => ! isVisiblyHidden( img ) ); 11 | }; 12 | 13 | /** 14 | * Check if anchor has sufficient accessible text 15 | * @param {HTMLElement} node Anchor element to check 16 | * @return {boolean} True if anchor has accessible text 17 | */ 18 | export const hasAccessibleText = ( node ) => { 19 | const textContent = ( node.textContent || '' ).trim(); 20 | const hasText = textContent.length >= 5; 21 | const hasAriaLabel = node.getAttribute( 'aria-label' ) !== null && node.getAttribute( 'aria-label' ) !== ''; 22 | const hasTitle = node.getAttribute( 'title' ) !== null && node.getAttribute( 'title' ) !== ''; 23 | 24 | return hasText || hasAriaLabel || hasTitle; 25 | }; 26 | -------------------------------------------------------------------------------- /src/pageScanner/rules/aria-broken-reference.js: -------------------------------------------------------------------------------- 1 | export default { 2 | id: 'aria_broken_reference', 3 | selector: '[aria-labelledby], [aria-describedby], [aria-owns]', 4 | excludeHidden: true, 5 | tags: [ 6 | 7 | ], 8 | metadata: { 9 | description: 'Ensures ARIA attributes reference existing elements', 10 | help: 'ARIA attributes that reference other elements must point to elements that exist in the DOM', 11 | impact: 'critical', 12 | }, 13 | all: [], 14 | any: [ 'aria_label_not_found', 'aria_describedby_not_found', 'aria_owns_not_found' ], 15 | none: [], 16 | }; 17 | -------------------------------------------------------------------------------- /src/pageScanner/rules/aria-hidden-validation.js: -------------------------------------------------------------------------------- 1 | export default { 2 | id: 'aria_hidden_validation', 3 | selector: '[aria-hidden="true"]', 4 | excludeHidden: false, 5 | tags: [ 6 | 'wcag2a', 7 | 'wcag131', 8 | 'cat.aria', 9 | 'cat.semantics', 10 | ], 11 | metadata: { 12 | description: 'Ensures elements with aria-hidden="true" are used appropriately', 13 | help: 'Elements with aria-hidden="true" should not hide important content that is unavailable elsewhere', 14 | impact: 'serious', 15 | }, 16 | all: [], 17 | any: [ 'aria_hidden_valid_usage' ], 18 | none: [], 19 | }; 20 | -------------------------------------------------------------------------------- /src/pageScanner/rules/broken-anchor-link.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Rule: Broken skip anchor link 3 | * 4 | * Description: Check if the skip anchor link is broken. 5 | */ 6 | 7 | export default { 8 | id: 'broken_skip_anchor_link', 9 | selector: 'a[href^="#"]:not([href="#"]):not([role="button"])', 10 | tags: [ 'wcag2a', 'wcag131', 'wcag241', 'custom' ], 11 | metadata: { 12 | description: 'Check if the skip anchor link is broken or missing its target.', 13 | }, 14 | all: [], 15 | any: [ 'anchor_exists' ], 16 | none: [], 17 | }; 18 | -------------------------------------------------------------------------------- /src/pageScanner/rules/color-contrast-failure.js: -------------------------------------------------------------------------------- 1 | export default { 2 | id: 'color_contrast_failure', 3 | matches: 'color-contrast-matches', 4 | excludeHidden: false, 5 | tags: [ 6 | 'cat.color', 7 | 'wcag2aa', 8 | 'wcag143', 9 | 'TTv5', 10 | 'TT13.c', 11 | 'EN-301-549', 12 | 'EN-9.1.4.3', 13 | 'ACT', 14 | ], 15 | actIds: [ 'afw4f7', '09o5cg' ], 16 | metadata: { 17 | description: 18 | 'Ensures the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds', 19 | help: 'Elements must meet minimum color contrast ratio thresholds', 20 | }, 21 | all: [], 22 | any: [ 'color-contrast' ], 23 | none: [], 24 | }; 25 | -------------------------------------------------------------------------------- /src/pageScanner/rules/custom-rule-1.js: -------------------------------------------------------------------------------- 1 | export default { 2 | id: 'custom-rule-1', 3 | tags: [ 'custom' ], 4 | all: [ 'always-fail' ], 5 | }; 6 | -------------------------------------------------------------------------------- /src/pageScanner/rules/empty-button.js: -------------------------------------------------------------------------------- 1 | export default { 2 | id: 'empty_button', 3 | excludeHidden: false, 4 | selector: 'button, [role="button"], input[type="button"], input[type="submit"], input[type="reset"]', 5 | tags: [ 'accessibility', 'wcag2a', 'wcag2aa' ], 6 | metadata: { 7 | description: 'Ensures buttons have accessible labels or content.', 8 | help: 'Buttons must have accessible text, aria-label, or title attributes.', 9 | helpUrl: 'https://a11ychecker.com/help1960', 10 | }, 11 | 12 | any: [], 13 | all: [], 14 | none: [ 'button_is_empty' ], 15 | }; 16 | -------------------------------------------------------------------------------- /src/pageScanner/rules/empty-heading-tag.js: -------------------------------------------------------------------------------- 1 | export default { 2 | id: 'empty_heading_tag', 3 | selector: 'h1, h2, h3, h4, h5, h6', 4 | metadata: { 5 | description: 'Ensures headings have discernible text', 6 | help: 'Headings must have discernible text', 7 | helpUrl: 'https://a11ychecker.com/help1957', 8 | }, 9 | tags: [ 'wcag2a', 'best-practice' ], 10 | all: [], 11 | any: [ 'heading_is_empty' ], 12 | none: [], 13 | }; 14 | -------------------------------------------------------------------------------- /src/pageScanner/rules/empty-link.js: -------------------------------------------------------------------------------- 1 | export default { 2 | id: 'empty_link', 3 | selector: 'a[href]', 4 | tags: [ 'wcag2a', 'wcag2.4.4', 'wcag4.1.2' ], 5 | metadata: { 6 | description: 'Ensures links have discernible text', 7 | help: 'Links must have discernible text', 8 | helpUrl: 'https://a11ychecker.com/help4108', 9 | }, 10 | any: [], 11 | all: [], 12 | none: [ 'link-is-empty' ], 13 | }; 14 | -------------------------------------------------------------------------------- /src/pageScanner/rules/empty-paragraph.js: -------------------------------------------------------------------------------- 1 | export default { 2 | id: 'empty_paragraph_tag', 3 | selector: 'p', 4 | excludeHidden: false, 5 | tags: [ 6 | 'cat.text', 7 | 'best-practices', 8 | ], 9 | impact: 'moderate', 10 | metadata: { 11 | description: 'Detects empty paragraph tags', 12 | help: 'Paragraphs should not be used for layout purposes and should never be empty', 13 | }, 14 | all: [], 15 | any: [ 'paragraph_not_empty' ], 16 | none: [], 17 | }; 18 | -------------------------------------------------------------------------------- /src/pageScanner/rules/empty-table-header.js: -------------------------------------------------------------------------------- 1 | export default { 2 | id: 'empty_table_header', 3 | selector: 'th, [role="columnheader"], [role="rowheader"]', 4 | excludeHidden: false, 5 | tags: [ 'wcag2a', 'wcag1.3.1', 'wcag4.1.2' ], 6 | metadata: { 7 | description: 'Ensures table headers have discernible text', 8 | help: 'Table headers must have discernible text', 9 | helpUrl: 'https://a11ychecker.com/help4109', 10 | }, 11 | any: [], 12 | all: [], 13 | none: [ 'table_header_is_empty' ], 14 | }; 15 | -------------------------------------------------------------------------------- /src/pageScanner/rules/extended/label.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This is a modified version of the original rule from axe-core library. 3 | * 4 | * The matches is changed to remove 'image' as a bail condition. A new image alt check is added. The "non-empty-placeholder" check is removed. 5 | * 6 | * original rule: https://github.com/dequelabs/axe-core/blob/develop/lib/rules/label.json 7 | */ 8 | 9 | export default { 10 | id: 'label', 11 | impact: 'critical', 12 | selector: 'input, textarea', 13 | matches: ( node, virtualNode ) => { 14 | if ( virtualNode.props.nodeName !== 'input' || virtualNode.hasAttr( 'type' ) === false ) { 15 | return true; 16 | } 17 | const type2 = virtualNode.attr( 'type' ).toLowerCase(); 18 | // 'image' is a removed value compared to the original `label-matches` matcher. 19 | return [ 'hidden', 'button', 'submit', 'reset' ].includes( type2 ) === false; 20 | }, 21 | tags: [ 22 | 'cat.forms', 23 | 'wcag2a', 24 | 'wcag412', 25 | 'section508', 26 | 'section508.22.n', 27 | 'TTv5', 28 | 'TT5.c', 29 | 'EN-301-549', 30 | 'EN-9.4.1.2', 31 | 'ACT', 32 | ], 33 | actIds: [ 'e086e5' ], 34 | metadata: { 35 | description: 'Ensure every form element has a label', 36 | help: 'Form elements must have labels', 37 | }, 38 | all: [], 39 | any: [ 40 | 'implicit-label', 41 | 'explicit-label', 42 | 'aria-label', 43 | 'aria-labelledby', 44 | 'non-empty-title', 45 | 'presentational-role', 46 | 'image_input_has_alt', // this is added as a custom check. "non-empty-placeholder" was removed. 47 | ], 48 | none: [ 'hidden-explicit-label' ], 49 | }; 50 | -------------------------------------------------------------------------------- /src/pageScanner/rules/img-alt-empty.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Rule for detecting images with empty alt attributes. 3 | * Based on WCAG 1.1.1: Non-text Content (Level A) 4 | */ 5 | 6 | export default { 7 | id: 'img_alt_empty', 8 | selector: 'img[alt=""], input[type="image"][alt=""]', 9 | excludeHidden: true, 10 | tags: [ 'cat.text-alternatives', 'wcag1a', 'wcag111' ], 11 | all: [], 12 | any: [ 'img_alt_empty_check' ], 13 | none: [], 14 | metadata: { 15 | description: 'Ensures images with attributes alt="" are not used when they require alternative text', 16 | help: 'Images with empty alt attributes must be decorative or already described in context', 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /src/pageScanner/rules/img-alt-invalid.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Rule to check if images have valid alt text. 3 | * Based on WCAG 1.1.1 Non-text Content (Level A) 4 | */ 5 | 6 | export default { 7 | id: 'img_alt_invalid', 8 | selector: 'img', 9 | excludeHidden: true, 10 | any: [], 11 | all: [ 'img_alt_invalid_check' ], 12 | none: [], 13 | tags: [ 'wcag1a', 'wcag111', 'cat.text-alternatives' ], 14 | metadata: { 15 | description: 'Ensures images have valid alternative text', 16 | help: 'Images must have meaningful alt text rather than filenames or generic text', 17 | helpUrl: 'https://www.w3.org/WAI/WCAG21/Understanding/non-text-content.html', 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /src/pageScanner/rules/img-alt-long.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Rule for detecting images with alt text that is too long. 3 | * Based on WCAG 1.1.1: Non-text Content (Level A) 4 | */ 5 | 6 | export default { 7 | id: 'img_alt_long', 8 | selector: 'img[alt]', 9 | excludeHidden: true, 10 | tags: [ 'cat.text-alternatives', 'wcag1a', 'wcag111' ], 11 | all: [], 12 | any: [ 'img_alt_long_check' ], 13 | none: [], 14 | metadata: { 15 | description: 'Ensures images do not have excessively long alt text', 16 | help: 'Image alt text should be concise and not exceed 300 characters', 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /src/pageScanner/rules/img-alt-missing.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Rule to check if images have missing alt text. 3 | * Based on WCAG 1.1.1 Non-text Content (Level A) 4 | */ 5 | 6 | export default { 7 | id: 'img_alt_missing', 8 | selector: 'img, input[type="image"]', 9 | excludeHidden: true, 10 | any: [], 11 | all: [], 12 | none: [ 'img_alt_missing_check' ], 13 | tags: [ 'wcag1a', 'wcag111', 'cat.text-alternatives' ], 14 | metadata: { 15 | description: 'Ensures images have alt text', 16 | help: 'Images must have an alt attribute', 17 | helpUrl: 'https://www.w3.org/WAI/WCAG21/Understanding/non-text-content.html', 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /src/pageScanner/rules/img-alt-redundant.js: -------------------------------------------------------------------------------- 1 | export default { 2 | id: 'img_alt_redundant', 3 | selector: 'img, figure img', 4 | any: [ 'img_alt_redundant_check' ], 5 | none: [], 6 | tags: [ 'duplicate', 'redundant', 'accessibility' ], 7 | metadata: { 8 | description: 'Checks for redundant alternative text on images, including duplicate alt text across images; alt text matching title, link text or figcaption.', 9 | help: 'Ensure that each image has unique, meaningful alt text that does not duplicate related text (such as its title, associated link text, or accompanying caption).', 10 | helpUrl: 'https://a11ychecker.com/help1976', 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /src/pageScanner/rules/img-animated.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Detect animated gifs and webps as well as common animated gif services. 3 | */ 4 | export default { 5 | id: 'img_animated', 6 | // Selects both images and iframes that might contain animations 7 | selector: 'img[src], iframe[src]', 8 | excludeHidden: false, 9 | tags: [ 10 | 'wcag2aa', 11 | 'wcag222', 12 | 'cat.sensory-and-visual-cues', 13 | 'best-practice', 14 | 'flashing', 15 | ], 16 | metadata: { 17 | description: 'Identifies animated images that may require user controls', 18 | help: 'Animated images (not static GIFs/WebPs) should be limited to less than 5 seconds or provide user controls to pause/stop', 19 | impact: 'serious', 20 | issue: { 21 | type: 'warning', 22 | message: 'Animated image content might need controls for accessibility compliance', 23 | tips: [ 24 | 'Only animated images need controls, static GIFs/WebPs are fine', 25 | 'Limit animations to less than 5 seconds', 26 | 'Add controls to pause/stop animations', 27 | 'Consider using video elements with controls instead of animated GIFs', 28 | 'Avoid flashing content that could trigger seizures', 29 | ], 30 | }, 31 | }, 32 | all: [], 33 | any: [], 34 | none: [ 'img_animated_check' ], 35 | }; 36 | -------------------------------------------------------------------------------- /src/pageScanner/rules/img-linked-alt-empty.js: -------------------------------------------------------------------------------- 1 | export default { 2 | id: 'img_linked_alt_empty', 3 | selector: 'a', 4 | tags: [ 5 | 'wcag2a', 6 | 'wcag111', 7 | 'cat.text-alternatives', 8 | ], 9 | metadata: { 10 | description: 'Ensures linked images do not have empty alt text', 11 | help: 'Linked images must have meaningful alternative text describing the link purpose', 12 | impact: 'serious', 13 | }, 14 | all: [], 15 | any: [ 'linked_image_alt_not_empty' ], 16 | none: [], 17 | }; 18 | -------------------------------------------------------------------------------- /src/pageScanner/rules/img-linked-alt-missing.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Rule to detect the presence of linked images missing alternative text. 3 | * This rule ensures that linked images have meaningful alternative text 4 | * to describe the purpose of the link for accessibility. 5 | */ 6 | 7 | export default { 8 | id: 'img_linked_alt_missing', 9 | selector: 'a', 10 | tags: [ 11 | 'wcag2a', 12 | 'wcag111', 13 | 'cat.text-alternatives', 14 | ], 15 | metadata: { 16 | description: 'Checks that linked images have meaningful alternative text.', 17 | help: 'Linked images must have alternative text describing link purpose.', 18 | impact: 'serious', 19 | }, 20 | all: [], 21 | any: [ 'linked_image_alt_present' ], 22 | none: [], 23 | }; 24 | -------------------------------------------------------------------------------- /src/pageScanner/rules/link-ambiguous-text.js: -------------------------------------------------------------------------------- 1 | export default { 2 | id: 'link_ambiguous_text', 3 | enabled: true, 4 | selector: 'a', 5 | excludeHidden: false, 6 | tags: [ 7 | 'cat.text', 8 | 'best-practices', 9 | ], 10 | metadata: { 11 | description: 'Detects ambiguous link text', 12 | help: 'Links should have descriptive text to help users understand their purpose.', 13 | }, 14 | any: [], 15 | all: [], 16 | none: [ 'has_ambiguous_text' ], 17 | }; 18 | -------------------------------------------------------------------------------- /src/pageScanner/rules/link-improper.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Rule to detect improper use of tags that are missing meaningful href attributes 3 | * and are not being used as buttons (via role="button"). 4 | */ 5 | 6 | export default { 7 | id: 'link_improper', 8 | selector: 'a', 9 | tags: [ 10 | 'wcag2a', 11 | 'wcag412', 12 | 'cat.structure', 13 | ], 14 | metadata: { 15 | description: 'Links must have a meaningful href or an appropriate role if used as buttons.', 16 | help: 'Avoid using tags without href or with href="#" unless role="button" is used.', 17 | impact: 'serious', 18 | }, 19 | all: [], 20 | any: [ 'link_has_valid_href_or_role' ], 21 | none: [], 22 | }; 23 | -------------------------------------------------------------------------------- /src/pageScanner/rules/link-ms-office-file.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Detects linked MS Office files. 3 | */ 4 | 5 | const msOfficeFileExtensions = [ '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.pps', '.ppsx' ]; 6 | 7 | // This generates a very long list of selectors, 6 in total for each extension: extension at the end, 8 | // extension within having query vars, extension within having #anchors plus uppercase versions of the 9 | // file extension for each one of these cases. It is still quicker that iterating through all the links 10 | // on the page and checking the href in a matches. 11 | const selectorString = msOfficeFileExtensions.map( ( extension ) => `a[href$="${ extension }"], a[href$="${ extension.toUpperCase() }"], a[href*="${ extension }?"], a[href*="${ extension.toUpperCase() }?"], a[href*="${ extension }#"], a[href*="${ extension.toUpperCase() }#"]` ).join( ', ' ); 12 | 13 | export default { 14 | id: 'link_ms_office_file', 15 | selector: selectorString, 16 | excludeHidden: false, 17 | tags: [ 18 | 'cat.custom', 19 | ], 20 | metadata: { 21 | description: 'Links to MS Office documents typically should be checked.', 22 | }, 23 | all: [], 24 | any: [ 'always-fail' ], 25 | none: [], 26 | }; 27 | -------------------------------------------------------------------------------- /src/pageScanner/rules/link-non-html-file.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Rule to detect links pointing to non-HTML documents that may need a warning or alternate format. 3 | */ 4 | 5 | export default { 6 | id: 'link_non_html_file', 7 | selector: 'a[href]', 8 | tags: [ 9 | 'best-practice', 10 | 'cat.structure', 11 | ], 12 | metadata: { 13 | description: 'Links to non-HTML documents should be clearly labeled or avoided.', 14 | help: 'Avoid linking to non-HTML documents without warnings or alternatives.', 15 | impact: 'moderate', 16 | }, 17 | all: [], 18 | any: [ 'link_points_to_html' ], 19 | none: [], 20 | }; 21 | -------------------------------------------------------------------------------- /src/pageScanner/rules/link-pdf.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Detects linked PDFs. 3 | */ 4 | export default { 5 | id: 'link_pdf', 6 | selector: 'a[href$=".pdf"], a[href$=".PDF"], a[href*=".pdf?"], a[href*=".PDF?"], a[href*=".pdf#"], a[href*=".PDF#"]', 7 | excludeHidden: false, 8 | tags: [ 9 | 'cat.custom', 10 | ], 11 | metadata: { 12 | description: 'Links to PDFs typically should be checked.', 13 | }, 14 | all: [], 15 | any: [ 'always-fail' ], 16 | none: [], 17 | }; 18 | -------------------------------------------------------------------------------- /src/pageScanner/rules/link_target_blank.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Check for links that open in a new tab without informing the user. 3 | */ 4 | 5 | export default { 6 | id: 'link_blank', 7 | selector: 'a[target="_blank"]', 8 | excludeHidden: false, 9 | tags: [ 10 | 'cat.custom', 11 | 'wcag2aaa', 12 | 'wcag322', 13 | 'wcag325', 14 | ], 15 | metadata: { 16 | description: 'Links that open in a new tab should inform the user.', 17 | help: 'Links that open in a new tab should inform the user. This is important for users who rely on screen readers, as they may not realize that a new tab has opened.', 18 | }, 19 | all: [], 20 | any: [], 21 | none: [ 'link_target_blank_without_informing' ], 22 | }; 23 | -------------------------------------------------------------------------------- /src/pageScanner/rules/long-description-invalid.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Rule to detect the presence of invalid longdesc attributes on images. 3 | * This rule ensures that the longdesc attribute points to a valid, non-image resource 4 | * that provides a detailed description for the image. 5 | */ 6 | 7 | export default { 8 | id: 'long_description_invalid', 9 | selector: 'img[longdesc]', 10 | tags: [ 11 | 'wcag2a', 12 | 'wcag131', 13 | 'cat.text-alternatives', 14 | ], 15 | metadata: { 16 | description: 'Checks that longdesc attributes are valid and do not point to images.', 17 | help: 'longdesc should link to a non-image resource with a detailed description', 18 | impact: 'moderate', 19 | }, 20 | all: [], 21 | any: [ 'longdesc_valid' ], 22 | none: [], 23 | }; 24 | -------------------------------------------------------------------------------- /src/pageScanner/rules/missing-headings.js: -------------------------------------------------------------------------------- 1 | export default { 2 | id: 'missing_headings', 3 | selector: 'body', 4 | tags: [ 'wcag2a', 'best-practice' ], 5 | all: [], 6 | any: [ 'has_subheadings_if_long_content' ], 7 | none: [], 8 | metadata: { 9 | description: 'Ensures long content has appropriate heading structure', 10 | help: 'Content with more than 400 words should contain headings to improve readability and structure', 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /src/pageScanner/rules/missing-transcript.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Check to detect the presence of a transcript for media elements. 3 | * Ensures that audio, video, or iframe elements are accompanied by a transcript or a link to one. 4 | * 5 | * @param {Node} node The node to evaluate. 6 | * @return {boolean} True if a transcript is present, false otherwise. 7 | */ 8 | 9 | export default { 10 | id: 'missing_transcript', 11 | selector: 'audio, video, iframe, a[href]', 12 | excludeHidden: false, 13 | tags: [ 14 | 'wcag2a', 15 | 'wcag122', 16 | 'cat.time-and-media', 17 | ], 18 | metadata: { 19 | description: 'Media content should be accompanied by a text transcript', 20 | help: 'Ensure audio or video content includes a nearby transcript or transcript link', 21 | impact: 'serious', 22 | }, 23 | all: [], 24 | any: [ 'has_transcript' ], 25 | none: [], 26 | }; 27 | -------------------------------------------------------------------------------- /src/pageScanner/rules/possible-heading.js: -------------------------------------------------------------------------------- 1 | export default { 2 | id: 'possible_heading', 3 | selector: 'p', 4 | matches: ( node ) => { 5 | // Not inside a blockquote, figcaption or table cell 6 | return ! node.closest( 'blockquote, figcaption, td' ); 7 | }, 8 | excludeHidden: false, 9 | tags: [ 10 | 'wcag2a', 11 | 'wcag131', 12 | 'wcag241', 13 | 'cat.semantics', 14 | ], 15 | metadata: { 16 | description: 'Headings should be used to convey the structure of the page, not styled paragraphs', 17 | help: 'Paragraphs should not be styled to look like headings. Use the appropriate heading tag instead.', 18 | }, 19 | all: [], 20 | any: [], 21 | none: [ 'paragraph_styled_as_header' ], 22 | }; 23 | -------------------------------------------------------------------------------- /src/pageScanner/rules/slider-present.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Check to detect the presence of slider/carousel components that may require accessibility features. 3 | * Identifies various slider types including those with specific classes, data attributes, or roles. 4 | * 5 | * @param {Node} node The node to evaluate. 6 | * @return {boolean} False if the node is a slider/carousel element (triggering violation), true otherwise (no violation). 7 | */ 8 | 9 | export default { 10 | id: 'slider_present', 11 | selector: '[class], [data-jssor-slider], [data-layerslider-uid]', 12 | excludeHidden: false, 13 | tags: [ 'cat.structure' ], 14 | metadata: { 15 | description: 'Identifies presence of slider/carousel components that may require accessibility improvements', 16 | help: 'Sliders and carousels must be keyboard accessible and provide appropriate navigation controls', 17 | impact: 'moderate', 18 | }, 19 | all: [], 20 | any: [ 'slider_detected' ], 21 | none: [], 22 | }; 23 | -------------------------------------------------------------------------------- /src/pageScanner/rules/table-header-missing.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Check to detect the presence of table headers. 3 | * Ensures that tables have elements or appropriate scope attributes to define headers. 4 | * 5 | * @param {Node} node The node to evaluate. 6 | * @return {boolean} True if table headers are present, false otherwise. 7 | */ 8 | 9 | export default { 10 | id: 'missing_table_header', 11 | selector: 'table', 12 | excludeHidden: false, 13 | tags: [ 14 | 'wcag2a', 15 | 'wcag131', 16 | 'cat.structure', 17 | ], 18 | metadata: { 19 | description: 'Tables must have header cells to convey data relationships', 20 | help: 'Ensure that tables use elements with text or appropriate scope attributes', 21 | impact: 'serious', 22 | }, 23 | all: [], 24 | any: [ 'table_has_headers' ], 25 | none: [], 26 | }; 27 | -------------------------------------------------------------------------------- /src/pageScanner/rules/text-justified.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Rule: Text Justified 3 | * 4 | * Text elements should not be justified because it interferes with readability. 5 | */ 6 | 7 | const TEXT_JUSTIFIED_CHECK_THRESHOLD = 200; 8 | export default { 9 | id: 'text_justified', 10 | selector: 'p, span, small, strong, b, i, em, h1, h2, h3, h4, h5, h6, a, label, button, th, td, li, div, blockquote, address, cite, q, s, sub, sup, u, del, caption, dt, dd, figcaption, summary, data, time', 11 | matches: ( element ) => { 12 | return element.textContent.trim().length >= TEXT_JUSTIFIED_CHECK_THRESHOLD; 13 | }, 14 | tags: [ 'wcag2aaa', 'wcag148', 'cat.text', 'custom' ], 15 | metadata: { 16 | description: 'Text elements inside containers should not be justified.', 17 | }, 18 | all: [], 19 | any: [], 20 | none: [ 'text_is_justified' ], 21 | }; 22 | -------------------------------------------------------------------------------- /src/pageScanner/rules/text-small.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Rule: Text small failure 3 | * 4 | * Text should have a minimum size and anything below that is a fail. 5 | */ 6 | 7 | export default { 8 | id: 'text_small', 9 | impact: 'moderate', 10 | selector: 'p, span, small, strong, b, i, h1, h2, h3, h4, h5, h6, a, label, button, th, td, li, div, blockquote, address, cite, code, pre, q, s, sub, sup, u, var, abbr, acronym, del, dfn, em, ins, kbd, input, select, textarea, caption, dl, dt, dd, li, figure, figcaption, details, dialog, summary, data, time', 11 | matches: ( element ) => { 12 | // only run checks on elements with text content 13 | return element.textContent.trim().length; 14 | }, 15 | tags: [ 'wcag2aaa', 'wcag144', 'wcag148', 'cat.text' ], 16 | metadata: { 17 | description: 'Text elements should not be too small.', 18 | }, 19 | all: [], 20 | any: [], 21 | none: [ 'text_size_too_small' ], 22 | }; 23 | -------------------------------------------------------------------------------- /src/pageScanner/rules/underlined-text.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Rule: Underlined text failure 3 | * 4 | * Text elements should not use the `u` and text elements should not be 5 | * underlined unless they are links. 6 | */ 7 | 8 | export default { 9 | id: 'underlined_text', 10 | impact: 'moderate', 11 | selector: '*:not(a):not(html):not(head):not(body)', 12 | matches: ( element ) => { 13 | // U tags we will always check. 14 | if ( element.tagName.toLowerCase() === 'u' ) { 15 | return true; 16 | } 17 | 18 | // Traverse up the dom 3 levels and check if the element is inside an anchor. 19 | let parent = element.parentNode; 20 | let isInsideAnchor = false; 21 | for ( let i = 0; i < 3; i++ ) { 22 | // Can't go further up the dom if the parent is the html element. 23 | if ( parent.tagName.toLowerCase() === 'html' ) { 24 | break; 25 | } 26 | if ( parent && parent.tagName.toLowerCase() === 'a' ) { 27 | isInsideAnchor = true; 28 | break; 29 | } 30 | parent = parent.parentNode; 31 | } 32 | 33 | // If in an anchor, don't check underline. 34 | return ! isInsideAnchor; 35 | }, 36 | tags: [ 'wcag324', 'wcag21aa', 'cat.text', 'custom' ], 37 | metadata: { 38 | description: 'Text elements should not be underlined unless they are links.', 39 | }, 40 | all: [], 41 | any: [], 42 | none: [ 'element_is_u_tag', 'element_has_computed_underline' ], 43 | }; 44 | -------------------------------------------------------------------------------- /src/pageScanner/rules/video-present.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Rule to detect the presence of video content that may require accessibility features. 3 | * This rule works with the video_detected check to identify various video elements 4 | * that might need captions, audio descriptions, or other accessibility enhancements. 5 | */ 6 | 7 | export default { 8 | id: 'video_present', 9 | selector: 'video, iframe, object, source, [src], [role]', 10 | excludeHidden: false, 11 | tags: [ 12 | 'wcag2a', 13 | 'wcag121', 14 | 'wcag122', 15 | 'wcag123', 16 | 'cat.time-and-media', 17 | 'cat.sensory', 18 | ], 19 | metadata: { 20 | description: 'Identifies presence of video content that may require accessibility features', 21 | help: 'Video content should have appropriate alternatives like captions and audio descriptions', 22 | impact: 'serious', 23 | }, 24 | all: [], 25 | any: [], 26 | none: [ 'is_video_detected' ], 27 | }; 28 | -------------------------------------------------------------------------------- /src/pageScanner/utils/aria-utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Check if ARIA attributes that reference elements by ID point to existing elements. 3 | * 4 | * @param {Node} node The node to evaluate. 5 | * @param {string} attributeName The ARIA attribute name to check (e.g., 'aria-labelledby'). 6 | * @return {boolean} True if a non-empty references exist, false otherwise. 7 | */ 8 | export function checkAriaReferences( node, attributeName ) { 9 | const attrValue = node.getAttribute( attributeName ) || ''; 10 | if ( ! attrValue.trim() ) { 11 | return false; // Skip empty values 12 | } 13 | const ids = attrValue.split( /\s+/ ).filter( ( id ) => id.trim() ); 14 | return ids.length === 0 || ids.every( ( id ) => document.getElementById( id ) !== null ); 15 | } 16 | -------------------------------------------------------------------------------- /tests/assets/animated.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equalizedigital/accessibility-checker/7c52a7c53a6967dbf3d18f90e69b5a58a56f5f91/tests/assets/animated.gif -------------------------------------------------------------------------------- /tests/assets/animated.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equalizedigital/accessibility-checker/7c52a7c53a6967dbf3d18f90e69b5a58a56f5f91/tests/assets/animated.webp -------------------------------------------------------------------------------- /tests/assets/static.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equalizedigital/accessibility-checker/7c52a7c53a6967dbf3d18f90e69b5a58a56f5f91/tests/assets/static.gif -------------------------------------------------------------------------------- /tests/assets/static.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equalizedigital/accessibility-checker/7c52a7c53a6967dbf3d18f90e69b5a58a56f5f91/tests/assets/static.webp -------------------------------------------------------------------------------- /tests/bootstrap-dev.php: -------------------------------------------------------------------------------- 1 | { 4 | let map; 5 | 6 | beforeEach( () => { 7 | map = new NormalizedMap(); 8 | } ); 9 | 10 | test( 'should store and retrieve keys in a case-insensitive manner', () => { 11 | map.set( 'TestKey', 'value' ); 12 | expect( map.get( 'testkey' ) ).toBe( 'value' ); 13 | expect( map.get( 'TESTKEY' ) ).toBe( 'value' ); 14 | expect( map.get( 'TestKey' ) ).toBe( 'value' ); 15 | } ); 16 | 17 | test( 'should check existence of keys in a case-insensitive manner', () => { 18 | map.set( 'AnotherKey', 'value' ); 19 | expect( map.has( 'anotherkey' ) ).toBe( true ); 20 | expect( map.has( 'ANOTHERKEY' ) ).toBe( true ); 21 | expect( map.has( 'AnotherKey' ) ).toBe( true ); 22 | } ); 23 | 24 | test( 'should delete keys in a case-insensitive manner', () => { 25 | map.set( 'KeyToDelete', 'value' ); 26 | expect( map.has( 'keytodelete' ) ).toBe( true ); 27 | 28 | map.delete( 'KEYTODELETE' ); 29 | expect( map.has( 'KeyToDelete' ) ).toBe( false ); 30 | } ); 31 | 32 | test( 'should handle non-string keys without normalization', () => { 33 | const objKey = {}; 34 | map.set( objKey, 'value' ); 35 | expect( map.get( objKey ) ).toBe( 'value' ); 36 | expect( map.has( objKey ) ).toBe( true ); 37 | 38 | map.delete( objKey ); 39 | expect( map.has( objKey ) ).toBe( false ); 40 | } ); 41 | 42 | test( 'should handle mixed string and non-string keys correctly', () => { 43 | map.set( 'StringKey', 'stringValue' ); 44 | map.set( 123, 'numberValue' ); 45 | 46 | expect( map.get( 'stringkey' ) ).toBe( 'stringValue' ); 47 | expect( map.get( 123 ) ).toBe( 'numberValue' ); 48 | } ); 49 | } ); 50 | -------------------------------------------------------------------------------- /tests/jest/jest.config.js: -------------------------------------------------------------------------------- 1 | /* global module */ 2 | module.exports = { 3 | testEnvironment: 'jsdom', 4 | transform: { 5 | '^.+\\.js$': [ 'babel-jest', { configFile: require.resolve( '../jest/babel.config.js' ) } ] }, 6 | transformIgnorePatterns: [ 7 | 'node_modules/(?!(axe-core)/)', 8 | ], 9 | testMatch: [ 10 | '**/tests/jest/**/*.test.js', 11 | ], 12 | }; 13 | -------------------------------------------------------------------------------- /tests/jest/mock-assets/A-image.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equalizedigital/accessibility-checker/7c52a7c53a6967dbf3d18f90e69b5a58a56f5f91/tests/jest/mock-assets/A-image.gif -------------------------------------------------------------------------------- /tests/jest/mock-assets/A-image.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equalizedigital/accessibility-checker/7c52a7c53a6967dbf3d18f90e69b5a58a56f5f91/tests/jest/mock-assets/A-image.webp -------------------------------------------------------------------------------- /tests/jest/mock-assets/S-1x1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equalizedigital/accessibility-checker/7c52a7c53a6967dbf3d18f90e69b5a58a56f5f91/tests/jest/mock-assets/S-1x1.gif -------------------------------------------------------------------------------- /tests/jest/mock-assets/S-animated-in-name.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equalizedigital/accessibility-checker/7c52a7c53a6967dbf3d18f90e69b5a58a56f5f91/tests/jest/mock-assets/S-animated-in-name.gif -------------------------------------------------------------------------------- /tests/jest/mock-assets/S-animated-in-name.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equalizedigital/accessibility-checker/7c52a7c53a6967dbf3d18f90e69b5a58a56f5f91/tests/jest/mock-assets/S-animated-in-name.webp -------------------------------------------------------------------------------- /tests/jest/mock-assets/S-image.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equalizedigital/accessibility-checker/7c52a7c53a6967dbf3d18f90e69b5a58a56f5f91/tests/jest/mock-assets/S-image.gif -------------------------------------------------------------------------------- /tests/jest/mock-assets/S-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equalizedigital/accessibility-checker/7c52a7c53a6967dbf3d18f90e69b5a58a56f5f91/tests/jest/mock-assets/S-image.jpg -------------------------------------------------------------------------------- /tests/jest/mock-assets/S-image.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/equalizedigital/accessibility-checker/7c52a7c53a6967dbf3d18f90e69b5a58a56f5f91/tests/jest/mock-assets/S-image.webp -------------------------------------------------------------------------------- /tests/jest/pageScanner/context.test.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Test for scanner context exclusions 4 | * 5 | * This test verifies that elements with the selectors in the exclude array 6 | * are properly excluded from accessibility scans, including the newly added 7 | * #qm-icon-container selector. 8 | */ 9 | import axe from 'axe-core'; 10 | import { exclusionsArray } from '../../../src/pageScanner/config/exclusions'; 11 | 12 | describe( 'Scanner Context Exclusions', ( ) => { 13 | beforeEach( ( ) => { 14 | // Reset the DOM before each test 15 | document.body.innerHTML = ''; 16 | } ); 17 | 18 | test( 'should exclude configured containers from scan', async ( ) => { 19 | // Create test HTML with empty buttons (accessibility violation) 20 | // Some in regular content, others in containers that should be excluded 21 | document.body.innerHTML = ` 22 | 23 | 24 | 25 | 26 |
27 | 28 |
29 |
30 | 31 |
32 |
33 | 34 |
35 |
36 | 37 |
38 | `; 39 | 40 | // Define context with exclude list matching src/pageScanner/index.js 41 | const context = { 42 | exclude: exclusionsArray, 43 | }; 44 | 45 | // Run axe with button-name rule to detect empty buttons 46 | const results = await axe.run( context, { 47 | runOnly: [ 'button-name' ], 48 | } ); 49 | 50 | // Get all HTML from the violation nodes 51 | const violationHTML = results.violations 52 | .flatMap( ( violation ) => violation.nodes ) 53 | .map( ( node ) => node.html ); 54 | 55 | // Control button should appear in violations 56 | expect( violationHTML.some( ( html ) => html.includes( 'id="control-button"' ) ) ).toBe( true ); 57 | 58 | // Buttons in excluded containers should not appear in violations 59 | expect( violationHTML.some( ( html ) => html.includes( 'id="qm-icon-button"' ) ) ).toBe( false ); 60 | expect( violationHTML.some( ( html ) => html.includes( 'id="wpadminbar-button"' ) ) ).toBe( false ); 61 | expect( violationHTML.some( ( html ) => html.includes( 'id="query-monitor-button"' ) ) ).toBe( false ); 62 | expect( violationHTML.some( ( html ) => html.includes( 'id="edac-panel-button"' ) ) ).toBe( false ); 63 | } ); 64 | } ); 65 | -------------------------------------------------------------------------------- /tests/jest/rules/missingHeadings.test.js: -------------------------------------------------------------------------------- 1 | import axe from 'axe-core'; 2 | 3 | beforeAll( async () => { 4 | const ruleModule = await import( '../../../src/pageScanner/rules/missing-headings.js' ); 5 | const checkModule = await import( '../../../src/pageScanner/checks/has-subheadings-if-long-content.js' ); 6 | 7 | axe.configure( { 8 | rules: [ ruleModule.default ], 9 | checks: [ checkModule.default ], 10 | } ); 11 | } ); 12 | 13 | describe( 'Missing Headings Rule', () => { 14 | test.each( [ 15 | // ✅ Passing cases 16 | { 17 | name: 'Passes with short content (under 400 words)', 18 | html: '

Short content.

', 19 | shouldPass: true, 20 | }, 21 | { 22 | name: 'Passes with long content and h2 heading', 23 | html: `
24 |

Section Title

25 | ${ 'Lorem ipsum '.repeat( 500 ) } 26 |
`, 27 | shouldPass: true, 28 | }, 29 | { 30 | name: 'Passes with long content and ARIA heading', 31 | html: `
32 |
Section Title
33 | ${ 'Lorem ipsum '.repeat( 500 ) } 34 |
`, 35 | shouldPass: true, 36 | }, 37 | 38 | // ❌ Failing cases 39 | { 40 | name: 'Fails with long content and no headings', 41 | html: `
${ 'Lorem ipsum '.repeat( 500 ) }
`, 42 | shouldPass: false, 43 | }, 44 | ] )( '$name', async ( { html, shouldPass } ) => { 45 | document.body.innerHTML = html; 46 | 47 | const results = await axe.run( document.body, { 48 | runOnly: [ 'missing_headings' ], 49 | } ); 50 | 51 | if ( shouldPass ) { 52 | expect( results.violations.length ).toBe( 0 ); 53 | } else { 54 | expect( results.violations.length ).toBeGreaterThan( 0 ); 55 | } 56 | } ); 57 | } ); 58 | -------------------------------------------------------------------------------- /tests/jest/rules/sliderPresent.test.js: -------------------------------------------------------------------------------- 1 | import axe from 'axe-core'; 2 | 3 | let sliderClassKeywords = []; 4 | 5 | beforeAll( async () => { 6 | const sliderRuleModule = await import( '../../../src/pageScanner/rules/slider-present.js' ); 7 | const sliderCheckModule = await import( '../../../src/pageScanner/checks/slider-detected.js' ); 8 | 9 | const sliderRule = sliderRuleModule.default; 10 | const sliderCheck = sliderCheckModule.default; 11 | 12 | sliderClassKeywords = sliderCheckModule.sliderClassKeywords; 13 | 14 | axe.configure( { 15 | rules: [ sliderRule ], 16 | checks: [ sliderCheck ], 17 | } ); 18 | } ); 19 | 20 | beforeEach( () => { 21 | document.body.innerHTML = ''; 22 | } ); 23 | 24 | describe( 'Slider Detection Rule', () => { 25 | test.each( [ 26 | ...sliderClassKeywords.map( ( keyword ) => ( { 27 | name: `detects slider with class "${ keyword }"`, 28 | html: `
`, 29 | shouldPass: false, 30 | } ) ), 31 | { 32 | name: 'detects slider with multiple classes including a slider class', 33 | html: '
', 34 | shouldPass: false, 35 | }, 36 | { 37 | name: 'handles nested slider elements correctly', 38 | html: '
', 39 | shouldPass: false, 40 | }, 41 | { 42 | name: 'ignores case variations in class names', 43 | html: '
', 44 | shouldPass: false, 45 | }, 46 | { 47 | name: 'detects data-jssor-slider', 48 | html: '
', 49 | shouldPass: false, 50 | }, 51 | { 52 | name: 'detects data-layerslider-uid', 53 | html: '
', 54 | shouldPass: false, 55 | }, 56 | { 57 | name: 'ignores unrelated div with no classes', 58 | html: '
', 59 | shouldPass: true, 60 | }, 61 | { 62 | name: 'ignores unrelated section with text content', 63 | html: '
Just content
', 64 | shouldPass: true, 65 | }, 66 | { 67 | name: 'ignores similar but unrelated class name', 68 | html: '
', 69 | shouldPass: true, 70 | }, 71 | { 72 | name: 'ignores [data-slider-id] which is not a match', 73 | html: '
', 74 | shouldPass: true, 75 | }, 76 | ] )( '$name', async ( { html, shouldPass } ) => { 77 | document.body.innerHTML = html; 78 | 79 | const results = await axe.run( document.body, { 80 | runOnly: [ 'slider_present' ], 81 | } ); 82 | 83 | if ( shouldPass ) { 84 | expect( results.violations.length ).toBe( 0 ); 85 | } else { 86 | expect( results.violations.length ).toBeGreaterThan( 0 ); 87 | } 88 | } ); 89 | } ); 90 | -------------------------------------------------------------------------------- /tests/phpunit/Admin/MetaBoxesTest.php: -------------------------------------------------------------------------------- 1 | getMockBuilder( Meta_Boxes::class ) 24 | ->onlyMethods( [ 'register_meta_boxes' ] ) 25 | ->getMock(); 26 | 27 | $meta_boxes->expects( $this->once() ) 28 | ->method( 'register_meta_boxes' ); 29 | 30 | $this->invoke_admin_init( $meta_boxes ); 31 | do_action( 'add_meta_boxes' ); 32 | } 33 | 34 | /** 35 | * Test the init_hooks method. 36 | * 37 | * @return void 38 | */ 39 | public function test_init_hooks(): void { 40 | $meta_boxes = new Meta_Boxes(); 41 | $meta_boxes->init_hooks(); 42 | 43 | $this->assertEquals( 44 | 10, 45 | has_action( 46 | 'add_meta_boxes', 47 | [ 48 | $meta_boxes, 49 | 'register_meta_boxes', 50 | ] 51 | ) 52 | ); 53 | } 54 | 55 | /** 56 | * Test the render method. 57 | * 58 | * @return void 59 | */ 60 | public function test_render(): void { 61 | $meta_boxes = new Meta_Boxes(); 62 | $meta_boxes->render(); 63 | 64 | $this->expectOutputRegex( '/^
expectOutputRegex( '/role="tablist"/' ); 66 | $this->expectOutputRegex( '/role="tab"/' ); 67 | $this->expectOutputRegex( '/role="tabpanel"/' ); 68 | } 69 | 70 | /** 71 | * Invoke the admin init method. 72 | * 73 | * @param Meta_Boxes $meta_boxes The metabox class. 74 | * @return void 75 | */ 76 | private function invoke_admin_init( $meta_boxes ): void { 77 | $admin = new Admin( $meta_boxes ); 78 | $admin->init(); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /tests/phpunit/RegisterRulesTest.php: -------------------------------------------------------------------------------- 1 | assertIsArray( $rules ); 19 | $this->assertNotEmpty( $rules ); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/phpunit/TestHelpers/DatabaseHelpers.php: -------------------------------------------------------------------------------- 1 | edac_update_database(); 27 | } 28 | /** 29 | * Insert a record to the database for a given post. 30 | * 31 | * @param WP_Post $post The post to insert the record for. 32 | * 33 | * @return void 34 | */ 35 | public static function insert_test_issue_to_db( WP_Post $post ): void { 36 | 37 | global $wpdb; 38 | $table_name = $wpdb->prefix . 'accessibility_checker'; 39 | $wpdb->insert( // phpcs:ignore WordPress.DB -- using direct query for testing. 40 | $table_name, 41 | [ 42 | 'postid' => $post->ID, 43 | 'siteid' => get_current_blog_id(), 44 | 'type' => $post->post_type, 45 | 'rule' => 'empty_paragraph_tag', 46 | 'ruletype' => 'warning', 47 | 'object' => '

', 48 | 'recordcheck' => 1, 49 | 'user' => get_current_user_id(), 50 | 'ignre' => 0, 51 | 'ignre_user' => null, 52 | 'ignre_date' => null, 53 | 'ignre_comment' => null, 54 | 'ignre_global' => 0, 55 | ] 56 | ); 57 | } 58 | 59 | /** 60 | * Drops the table for the plugin if it exists. 61 | * 62 | * Used for cleanup after tests. 63 | * 64 | * @return void 65 | */ 66 | public static function drop_table() { 67 | global $wpdb; 68 | $table_name = $wpdb->prefix . 'accessibility_checker'; 69 | $wpdb->query( 'DROP TABLE IF EXISTS ' . $table_name ); // phpcs:ignore WordPress.DB -- query for a unit test. 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /tests/phpunit/helper-functions/CompareStringsTest.php: -------------------------------------------------------------------------------- 1 | assertSame( 24 | $expected, 25 | edac_compare_strings( $string1, $string2 ) 26 | ); 27 | } 28 | 29 | /** 30 | * Data provider for test_edac_compare_strings. 31 | */ 32 | public function edac_compare_strings_data() { 33 | return [ 34 | 'mixed upper/lower letters' => [ 35 | 'string1' => 'random string', 36 | 'string2' => 'RanDom StrIng', 37 | 'expected' => true, 38 | ], 39 | 'different casing with same html tags' => [ 40 | 'string1' => '

random string

', 41 | 'string2' => '

RanDom StrIng

', 42 | 'expected' => true, 43 | ], 44 | 'with different html tags' => [ 45 | 'string1' => '

random string

', 46 | 'string2' => '
random string
', 47 | 'expected' => true, 48 | ], 49 | 'containing "permalink of/to" strings' => [ 50 | 'string1' => 'permalink of random string', 51 | 'string2' => 'permalink to random string', 52 | 'expected' => true, 53 | ], 54 | 'containing " " strings' => [ 55 | 'string1' => 'random  string', 56 | 'string2' => 'random string', 57 | 'expected' => true, 58 | ], 59 | 'different strings' => [ 60 | 'string1' => 'random string', 61 | 'string2' => 'different string', 62 | 'expected' => false, 63 | ], 64 | ]; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /tests/phpunit/includes/classes/SimplifiedSummaryTest.php: -------------------------------------------------------------------------------- 1 | simplified_summary = new Simplified_Summary(); 35 | } 36 | 37 | /** 38 | * Tests output of simplified_summary_markup with a summary. 39 | * 40 | * Verifies that the correct HTML markup is returned when a post 41 | * has a simplified summary. 42 | * 43 | * @return void 44 | */ 45 | public function test_simplified_summary_markup_with_summary() { 46 | $post_id = self::factory()->post->create(); 47 | update_post_meta( $post_id, '_edac_simplified_summary', 'This is a simplified summary.' ); 48 | 49 | $output = $this->simplified_summary->simplified_summary_markup( $post_id ); 50 | $this->assertStringContainsString( '
', $output ); 51 | $this->assertStringContainsString( '

Simplified Summary

', $output ); 52 | $this->assertStringContainsString( '

This is a simplified summary.

', $output ); 53 | $this->assertStringContainsString( '
', $output ); 54 | } 55 | 56 | /** 57 | * Tests output of simplified_summary_markup without a summary. 58 | * 59 | * Ensures that an empty string is returned when a post does not 60 | * have a simplified summary. 61 | * 62 | * @return void 63 | */ 64 | public function test_simplified_summary_markup_without_summary() { 65 | $post_id = self::factory()->post->create(); 66 | 67 | $output = $this->simplified_summary->simplified_summary_markup( $post_id ); 68 | $this->assertEmpty( $output ); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /tests/phpunit/includes/classes/SummaryGeneratorTest.php: -------------------------------------------------------------------------------- 1 | post->create(); 22 | update_post_meta( $post_id, '_edac_density_data', '0,0' ); 23 | 24 | $simplified_summary = new Summary_Generator( $post_id ); 25 | 26 | // Reflection means that the method was hard to test in isolation and 27 | // likely warrants a refactor so that the method is more testable. 28 | $method = ( new ReflectionClass( get_class( $simplified_summary ) ) ) 29 | ->getMethod( 'update_issue_density' ); 30 | $method->setAccessible( true ); 31 | 32 | $method->invoke( $simplified_summary, [] ); 33 | 34 | // We are really testing here that the method does not throw an error, 35 | // but we may as well check that the meta didn't change as well since 36 | // we are here and by this point already know the method did not fatal. 37 | $this->assertEquals( 38 | '0,0', 39 | get_post_meta( $post_id, '_edac_density_data', true ) 40 | ); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /tests/phpunit/includes/classes/WPCLI/BootstrapCLITest.php: -------------------------------------------------------------------------------- 1 | get_subcommands() ); 45 | 46 | $bootstrap_cli = new BootstrapCLI( new WP_CLI() ); 47 | $bootstrap_cli->register(); 48 | 49 | $commands = WP_CLI::get_root_command(); 50 | $command_count_after = count( $commands->get_subcommands() ); 51 | 52 | // check if the number of commands has increased after register is called. 53 | $this->assertGreaterThan( $command_count, $command_count_after ); 54 | } 55 | 56 | /** 57 | * Test the bootstrap CLI command with a mock that throws an exception when 58 | * adding commands. 59 | */ 60 | public function test_bootstrap_cli_command_with_exception() { 61 | WP_CLI::set_add_command_should_throw( true ); 62 | 63 | $bootstrap_cli = new BootstrapCLI( new WP_CLI() ); 64 | 65 | ob_start(); 66 | $bootstrap_cli->register(); 67 | $output = ob_get_clean(); 68 | 69 | // check if the output contains the expected exception message. 70 | $this->assertStringStartsWith( 'Warning: Failed to register command', $output ); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /update-composer-config.php: -------------------------------------------------------------------------------- 1 |