Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,22 @@ Either bucket can live on either track. Credits are optional metadata any module

| Module | Bucket | Track | Adopted from |
|---|---|---|---|
| Media Library: disable infinite scroll by default | Feature | Practical | WordPress core |
| Heading-order validation | Experiment | TBD | — |
| Media Library: add view options | Feature | Practical | WordPress core |
| Block Validation Framework | Experiment | Core-track | validation-api (Troy Chaplin) |
| Core block accessibility rules | Feature | Practical | validation-api-core-blocks (Troy Chaplin) |
| Validation settings | Feature | Practical | validation-api-settings (Troy Chaplin) |

## Block validation subsystem

### Terminology

Two words do a lot of work in this subsystem, so to be precise: **validation** names the mechanism, **accessibility** names the subject a particular rule set tests.

- **Block Validation Framework** — the Experiment module that provides the mechanism. It is a named subsystem, so it takes title case; the other module labels are descriptive and use sentence case.
- **Validation API** — the developer-facing interface that framework exposes: the `validation_api_register_*()` functions, the `editor.validateBlock` / `editor.validateMeta` / `editor.validateEditor` JS filters, the `validation_api_check_level` filter, and `GET /wp-validation/v1/checks`. This is why the code prefix is `validation_api_`.
- **Validation check** — a single registered rule, whatever its subject.
- **Core block accessibility rules** — one consumer of the framework: WCAG-oriented checks for core blocks. Accessibility is what they test; validation is how they run.

Three of the first-party modules compose into a full block-editor validation subsystem:

1. **Block Validation Framework** — a plugin API (`validation_api_register_block_check()`, `validation_api_register_meta_check()`, `validation_api_register_editor_check()`), an `accessibility-lab/validation` data store, real-time debounced validation, publish locking, a Validation sidebar, and REST introspection at `GET /wp-validation/v1/checks`.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php
/**
* Global registration functions for the block-validation framework.
* Global registration functions for the Block Validation Framework module.
*
* Third-party plugins call these on `init`. They resolve to the module's
* Check_Registry instance held by the framework module. Defined only when
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php
/**
* REST introspection for the block-validation framework.
* REST introspection for the Block Validation Framework module.
*
* @package AccessibilityLab
*/
Expand Down
58 changes: 56 additions & 2 deletions includes/Modules/Features/Core_Block_Validation_Rules.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
* duplication check, non-descriptive alt patterns, gallery inheritance,
* required post/page title).
*
* Silently no-ops if the framework module isn't active.
* Silently no-ops if the Block Validation Framework module isn't active.
*
* @package AccessibilityLab
*/
Expand All @@ -23,31 +23,61 @@
use AccessibilityLab\Credits;
use AccessibilityLab\Track;

/**
* Core Block Validation Rules module.
*
* Presented to users as "Core block accessibility rules". Accessibility is the
* subject these rules test; validation is the mechanism they run on — the Block
* Validation Framework is the general mechanism, and this module is one
* consumer of it.
*
* The class name and the `core_block_validation_rules` id are deliberately left
* as they are: the id is persisted in the `accessibility_lab_settings` option,
* so renaming it would reset the module's toggle on existing installs.
*/
final class Core_Block_Validation_Rules extends Abstract_Module {

private const NS = 'accessibility-lab-core-blocks';

/**
* Returns the ID for the core block accessibility rules module.
*/
public function id(): string {
return 'core_block_validation_rules';
}

/**
* Returns the bucket for the core block accessibility rules module.
*/
public function bucket(): string {
return Bucket::FEATURE;
}

/**
* Returns the track for the core block accessibility rules module.
*/
public function track(): string {
return Track::PRACTICAL;
}

/**
* Returns the name for the core block accessibility rules module.
*/
public function name(): string {
return __( 'Core block accessibility rules', 'accessibility-lab' );
Comment thread
troychaplin marked this conversation as resolved.
}

/**
* Returns the description for the core block accessibility rules module.
*/
public function description(): string {
return __( 'WCAG-oriented validation checks for the image, button, table, heading, and gallery core blocks, plus required post/page titles. Requires the Block Validation Framework module.', 'accessibility-lab' );
}

public function credits(): ?Credits {
/**
* Returns the credits for the core block accessibility rules module.
*/
public function credits(): Credits {
return new Credits(
author: 'Troy Chaplin',
Comment thread
troychaplin marked this conversation as resolved.
source_plugin_slug: 'validation-api-core-blocks',
Expand All @@ -56,11 +86,17 @@ public function credits(): ?Credits {
);
}

/**
* Boots the core block accessibility rules module.
*/
public function boot(): void {
add_action( 'init', array( $this, 'register_checks' ) );
add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_editor_assets' ), 20 );
}

/**
* Enqueues editor assets for the core block accessibility rules.
*/
public function enqueue_editor_assets(): void {
$asset_file = ACCESSIBILITY_LAB_DIR . '/build/core-block-rules.asset.php';
if ( ! file_exists( $asset_file ) ) {
Expand Down Expand Up @@ -89,6 +125,9 @@ public function enqueue_editor_assets(): void {
);
}

/**
* Registers all core block accessibility checks.
*/
public function register_checks(): void {
if ( ! function_exists( 'validation_api_register_block_check' ) ) {
return;
Expand All @@ -108,6 +147,9 @@ public function register_checks(): void {
$this->register_editor_checks();
}

/**
* Registers image block accessibility checks.
*/
private function register_image_checks(): void {
validation_api_register_block_check(
'core/image',
Expand Down Expand Up @@ -181,6 +223,9 @@ private function register_image_checks(): void {
}
}

/**
* Registers button block accessibility checks.
*/
private function register_button_checks(): void {
validation_api_register_block_check(
'core/button',
Expand Down Expand Up @@ -208,6 +253,9 @@ private function register_button_checks(): void {
);
}

/**
* Registers table block accessibility checks.
*/
private function register_table_checks(): void {
validation_api_register_block_check(
'core/table',
Expand All @@ -223,6 +271,9 @@ private function register_table_checks(): void {
);
}

/**
* Registers heading block accessibility checks.
*/
private function register_heading_checks(): void {
validation_api_register_block_check(
'core/heading',
Expand All @@ -238,6 +289,9 @@ private function register_heading_checks(): void {
);
}

/**
* Registers editor accessibility checks.
*/
private function register_editor_checks(): void {
foreach ( array( 'post', 'page' ) as $post_type ) {
validation_api_register_editor_check(
Expand Down
9 changes: 8 additions & 1 deletion src/editor/core-block-rules/button.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ function stripHtml( html: string ): string {
* Minimal URL sanity check. A full Public Suffix List (PSL) integration is
* planned as a follow-up — for now we accept any parseable http(s), mailto:,
* tel:, or root-relative URL.
*
* @param raw URL as entered in the block's link control.
*/
function isPlausibleUrl( raw: string ): boolean {
const value = raw.trim();
Expand All @@ -38,7 +40,12 @@ function isPlausibleUrl( raw: string ): boolean {
addFilter(
'editor.validateBlock',
'accessibility-lab-core-blocks/button',
( isValid: boolean, blockType: string, attributes: Record< string, unknown >, checkName: string ) => {
(
isValid: boolean,
blockType: string,
attributes: Record< string, unknown >,
checkName: string
) => {
if ( blockType !== 'core/button' ) {
return isValid;
}
Expand Down
11 changes: 8 additions & 3 deletions src/editor/core-block-rules/gallery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@
* by iterating the gallery's inner image blocks.
*/

import { addFilter } from '@wordpress/hooks';
import { applyFilters } from '@wordpress/hooks';
import { addFilter, applyFilters } from '@wordpress/hooks';

type GalleryAttributes = {
images?: Array< Record< string, unknown > >;
Expand All @@ -20,7 +19,13 @@ const CHECK_MAP: Record< string, string > = {
addFilter(
'editor.validateBlock',
'accessibility-lab-core-blocks/gallery',
( isValid: boolean, blockType: string, attributes: GalleryAttributes, checkName: string, rule: unknown ) => {
(
isValid: boolean,
blockType: string,
attributes: GalleryAttributes,
checkName: string,
rule: unknown
) => {
if ( blockType !== 'core/gallery' ) {
return isValid;
}
Expand Down
24 changes: 19 additions & 5 deletions src/editor/core-block-rules/heading.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,25 @@ function flattenBlocks( blocks: Block[] ): Block[] {
addFilter(
'editor.validateBlock',
'accessibility-lab-core-blocks/heading',
( isValid: boolean, blockType: string, attributes: { level?: number }, checkName: string, _rule: unknown, /* extra */ ) => {
if ( blockType !== 'core/heading' || checkName !== 'check_heading_rank' ) {
(
isValid: boolean,
blockType: string,
attributes: { level?: number },
checkName: string
) => {
if (
blockType !== 'core/heading' ||
checkName !== 'check_heading_rank'
) {
return isValid;
}
const level = Number( attributes.level ?? 2 );
// Locate previous heading in document order.
const blocks = flattenBlocks(
( select( blockEditorStore ) as unknown as { getBlocks: () => Block[] } ).getBlocks()
(
select( blockEditorStore ) as unknown as {
getBlocks: () => Block[];
}
).getBlocks()
);
const headings = blocks.filter( ( b ) => b.name === 'core/heading' );
// We don't have clientId in the filter args; approximate by finding the
Expand All @@ -52,7 +63,10 @@ addFilter(
if ( headings.length === 0 ) {
return true;
}
const firstHeadingLevel = Number( headings[ 0 ].attributes?.level ?? 2 );
const level = Number( attributes.level ?? 2 );
const firstHeadingLevel = Number(
headings[ 0 ].attributes?.level ?? 2
);
if ( level === firstHeadingLevel ) {
return firstHeadingLevel <= 2;
}
Expand Down
18 changes: 11 additions & 7 deletions src/editor/core-block-rules/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,33 +15,37 @@ function stripHtml( html: string ): string {
addFilter(
'editor.validateBlock',
'accessibility-lab-core-blocks/image',
( isValid: boolean, blockType: string, attributes: Record< string, unknown >, checkName: string ) => {
(
isValid: boolean,
blockType: string,
attributes: Record< string, unknown >,
checkName: string
) => {
if ( blockType !== 'core/image' ) {
return isValid;
}
const alt = String( attributes.alt ?? '' ).trim();
const caption = stripHtml( String( attributes.caption ?? '' ) );
const isDecorative = Boolean( attributes.isDecorative );
const hasImage = Boolean( attributes.url || attributes.id );

switch ( checkName ) {
case 'check_image_alt_text':
if ( ! hasImage || isDecorative ) {
return true;
}
return alt.length > 0;
// Passes with no image yet, a decorative image, or non-empty alt text.
return ! hasImage || isDecorative || alt.length > 0;

case 'check_image_alt_text_length':
if ( ! alt ) {
return true;
}
return alt.length <= 125;

case 'check_image_alt_caption_match':
case 'check_image_alt_caption_match': {
const caption = stripHtml( String( attributes.caption ?? '' ) );
if ( ! alt || ! caption ) {
return true;
}
return alt.toLowerCase() !== caption.toLowerCase();
}

case 'check_image_alt_text_patterns':
if ( ! alt || isDecorative ) {
Expand Down
13 changes: 9 additions & 4 deletions src/editor/core-block-rules/post-title.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,26 @@ addFilter(
'editor.validateEditor',
'accessibility-lab-core-blocks/post-title',
( isValid: boolean, checkName: string ) => {
if ( checkName !== 'post_title_required_post' && checkName !== 'post_title_required_page' ) {
if (
checkName !== 'post_title_required_post' &&
checkName !== 'post_title_required_page'
) {
return isValid;
}
const currentPost = ( select( editorStore ) as unknown as {
const currentPost = select( editorStore ) as unknown as {
getCurrentPost: () => { type?: string };
getEditedPostAttribute: ( name: string ) => unknown;
} );
};
const postType = currentPost.getCurrentPost().type;
if ( checkName === 'post_title_required_post' && postType !== 'post' ) {
return true;
}
if ( checkName === 'post_title_required_page' && postType !== 'page' ) {
return true;
}
const title = String( currentPost.getEditedPostAttribute( 'title' ) ?? '' ).trim();
const title = String(
currentPost.getEditedPostAttribute( 'title' ) ?? ''
).trim();
return title.length > 0;
}
);
10 changes: 8 additions & 2 deletions src/editor/core-block-rules/table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@ type TableAttributes = {
addFilter(
'editor.validateBlock',
'accessibility-lab-core-blocks/table',
( isValid: boolean, blockType: string, attributes: TableAttributes, checkName: string ) => {
(
isValid: boolean,
blockType: string,
attributes: TableAttributes,
checkName: string
) => {
if ( blockType !== 'core/table' ) {
return isValid;
}
Expand All @@ -24,7 +29,8 @@ addFilter(
return isValid;
}

const hasHead = Array.isArray( attributes.head ) && attributes.head.length > 0;
const hasHead =
Array.isArray( attributes.head ) && attributes.head.length > 0;
if ( hasHead ) {
return true;
}
Expand Down
Loading