self::LONG_LABEL ) { $findings[] = self::finding( 'long_label', $field, sprintf( /* translators: %s: field label. */ __( 'The label “%s” is long. Keep the question short and move detail into help text.', 'accessible-forms-by-accessible-org' ), self::excerpt( $label ) ) ); } if ( 'section' === ( $field['type'] ?? '' ) || '' === $plain ) { continue; } if ( isset( $seen[ $plain ] ) ) { $findings[] = self::finding( 'duplicate_label', $field, sprintf( /* translators: %s: field label. */ __( 'Two questions are labeled “%s”. People using screen readers or voice control cannot tell them apart; make each label unique.', 'accessible-forms-by-accessible-org' ), $label ) ); } $seen[ $plain ] = true; } return $findings; } /** * Help text that is too long to read comfortably. * * @param array $schema Schema. * @return array[] */ private static function help( array $schema ) { $findings = array(); foreach ( $schema['fields'] ?? array() as $field ) { if ( self::length( (string) ( $field['help'] ?? '' ) ) > self::LONG_HELP ) { $findings[] = self::finding( 'long_help', $field, sprintf( /* translators: %s: field label. */ __( 'The help text for “%s” is long. Keep the key instruction to a sentence or two; link to more detail if needed.', 'accessible-forms-by-accessible-org' ), $field['label'] ) ); } } return $findings; } /** * Forms that require almost everything. * * @param array $schema Schema. * @return array[] */ private static function required( array $schema ) { $answers = 0; $required = 0; foreach ( $schema['fields'] ?? array() as $field ) { if ( 'section' === ( $field['type'] ?? '' ) ) { continue; } ++$answers; $required += empty( $field['required'] ) ? 0 : 1; } if ( $required >= 8 || ( $answers >= 6 && $required === $answers ) ) { return array( self::finding( 'required_heavy', array(), sprintf( /* translators: 1: number of required questions, 2: total number of questions. */ __( '%1$d of %2$d questions are required. Ask only for what you need to respond; optional questions are marked clearly for visitors.', 'accessible-forms-by-accessible-org' ), $required, $answers ) ), ); } return array(); } /** * Forms without an email address cannot be found by privacy tools. * * @param array $schema Schema. * @return array[] */ private static function email( array $schema ) { foreach ( $schema['fields'] ?? array() as $field ) { if ( 'email' === ( $field['type'] ?? '' ) ) { return array(); } } return array( self::finding( 'no_email', array(), __( 'This form has no email question. WordPress privacy tools find submissions by email address, so export and erasure requests for this form need a manual search.', 'accessible-forms-by-accessible-org' ) ), ); } /** * A form heading that skips a level after the page's previous heading. * * @param array $schema Schema. * @param array $context Context with places. * @return array[] */ private static function headings( array $schema, array $context ) { $level = $schema['heading_level'] ?? 'h2'; if ( ! preg_match( '/^h([2-6])$/', $level, $match ) ) { return array(); } $own = (int) $match[1]; $findings = array(); foreach ( (array) ( $context['places'] ?? array() ) as $place ) { $before = (int) ( $place['level'] ?? 1 ); if ( $own > $before + 1 ) { $finding = self::finding( 'heading_context', array(), sprintf( /* translators: 1: page title, 2: previous heading level, 3: form heading level, 4: suggested heading level. */ __( 'On “%1$s”, the form heading jumps from level %2$d to level %3$d. Choose Heading %4$d in Heading options so the page outline has no gap.', 'accessible-forms-by-accessible-org' ), $place['title'], $before, $own, min( 6, $before + 1 ) ) ); $finding['key'] = 'heading_context:' . (int) ( $place['id'] ?? 0 ); $findings[] = $finding; } } return $findings; } /** * Build one finding. * * @param string $code Rule code. * @param array $field Field, or empty for form-level advice. * @param string $message Advice. * @return array */ public static function finding( $code, array $field, $message ) { $uid = (string) ( $field['uid'] ?? ( $field['name'] ?? '' ) ); return array( 'key' => $code . ':' . $uid, 'code' => $code, 'uid' => $uid, 'name' => (string) ( $field['name'] ?? '' ), 'label' => (string) ( $field['label'] ?? '' ), 'message' => $message, 'step' => 'build', ); } /** * Lowercase, trimmed, punctuation-free label for comparison. * * @param string $label Label. * @return string */ private static function normalize( $label ) { $label = function_exists( 'mb_strtolower' ) ? mb_strtolower( $label ) : strtolower( $label ); return trim( preg_replace( '/\s+/u', ' ', preg_replace( '/[^\p{L}\p{N}\s]+/u', ' ', $label ) ) ); } /** * Whether a normalized label says nothing about the answer. * * @param string $plain Normalized label. * @return bool */ private static function is_vague( $plain ) { if ( '' === $plain ) { return false; } if ( preg_match_all( '/\p{L}/u', $plain ) <= 2 && ! preg_match( '/\p{N}/u', $plain ) ) { return true; } if ( preg_match( '/^(field|question|input|text|item|entry|box|textbox|untitled field)( \d+)?$/u', $plain ) || preg_match( '/^(other|others|details|info|information|untitled|misc|miscellaneous|data|value|answer|response|n a|na|comments?)$/u', $plain ) ) { return true; } /** * Extra labels (lowercase, without punctuation) to treat as vague, for * example translations of "Other" or "Details" in the site's language. * * @param string[] $labels Labels. */ $extra = array_map( array( self::class, 'normalize' ), array_filter( (array) Extensions::filter( 'accessible_forms_vague_labels', array() ), 'is_string' ) ); return in_array( $plain, $extra, true ); } /** * Whether a label reads like placeholder instructions. * * @param string $label Label. * @return bool */ private static function is_placeholder( $label ) { return (bool) preg_match( '/^(please\s+)?(enter|type|input|insert|fill\s+in|write)\b/iu', $label ) || (bool) preg_match( '/^your\b.*\bhere$/iu', $label ) || (bool) preg_match( '/(\.\.\.|…)\s*$/u', $label ); } /** * Character length. * * @param string $text Text. * @return int */ private static function length( $text ) { return function_exists( 'mb_strlen' ) ? mb_strlen( $text ) : strlen( $text ); } /** * First 60 characters of a long label. * * @param string $text Text. * @return string */ private static function excerpt( $text ) { return self::length( $text ) > 60 ? ( function_exists( 'mb_substr' ) ? mb_substr( $text, 0, 57 ) : substr( $text, 0, 57 ) ) . '…' : $text; } }