rules = $rules; } public function rules() { return $this->rules; } public function total( $issues ) { return array_sum( array_map( 'intval', (array) $issues ) ); } public function scan_post( $post_id ) { return $this->scan( get_post_field( 'post_content', $post_id ) ); } public function details_post( $post_id ) { return $this->details( get_post_field( 'post_content', $post_id ) ); } public function scan( $content ) { $content = (string) $content; $issues = array(); $this->match_count( $issues, 'nested_strong', '/]*>\s*]*>/i', $content ); $this->match_count( $issues, 'nested_em', '/]*>\s*]*>/i', $content ); $this->match_count( $issues, 'empty_paragraph', '/]*>\s*(?: | |\s|)*<\/p>/i', $content ); preg_match_all( '/]*>(.*?)<\/p>/is', $content, $paragraphs ); $previous = null; $count = 0; foreach ( $paragraphs[1] as $paragraph ) { $current = strtolower( trim( preg_replace( '/\s+/', ' ', wp_strip_all_tags( html_entity_decode( $paragraph, ENT_QUOTES, 'UTF-8' ) ) ) ) ); if ( '' !== $current && null !== $previous && $current === $previous ) { $count++; } $previous = $current; } $this->set( $issues, 'duplicate_paragraph', $count ); preg_match_all( '/]*>.*?<\/h1>/is', $content, $h1s ); if ( count( $h1s[0] ) > 1 ) { $issues['multiple_h1'] = count( $h1s[0] ) - 1; } preg_match_all( '/]*>.*?<\/h\1>/is', $content, $headings ); $previous = null; $count = 0; foreach ( $headings[1] as $level ) { $level = (int) $level; if ( null !== $previous && $level > $previous + 1 ) { $count++; } $previous = $level; } $this->set( $issues, 'heading_skip', $count ); preg_match_all( '/\sid=["\']([^"\']+)["\']/i', $content, $ids ); $id_values = isset( $ids[1] ) ? $ids[1] : array(); $count = 0; foreach ( array_count_values( $id_values ) as $occurrences ) { if ( $occurrences > 1 ) { $count += $occurrences - 1; } } $this->set( $issues, 'duplicate_ids', $count ); preg_match_all( '/href=["\']#([^"\']+)["\']/i', $content, $anchors ); $count = 0; foreach ( $anchors[1] as $anchor ) { if ( ! in_array( $anchor, $id_values, true ) ) { $count++; } } $this->set( $issues, 'broken_anchor', $count ); preg_match_all( '/]*>/i', $content, $images ); $count = 0; foreach ( $images[0] as $tag ) { if ( ! preg_match( '/\salt=["\'][^"\']*["\']/i', $tag ) ) { $count++; } } $this->set( $issues, 'missing_alt', $count ); preg_match_all( '/]*target=["\']_blank["\'][^>]*>/i', $content, $links ); $count = 0; foreach ( $links[0] as $tag ) { if ( ! preg_match( '/\brel=["\'][^"\']*\b(?:noopener|noreferrer)\b[^"\']*["\']/i', $tag ) ) { $count++; } } $this->set( $issues, 'unsafe_blank_link', $count ); return apply_filters( 'owwcqc_scan_results', $issues, $content ); } /** * Return human-readable locations without changing the saved content. * Each item also contains a small needle used by admin.js to locate a * matching Gutenberg block. The needle is never written to the post. */ public function details( $content ) { $content = (string) $content; $details = array(); $this->regex_details( $details, 'nested_strong', '/]*>\s*]*>/i', $content, 'Nested bold markup' ); $this->regex_details( $details, 'nested_em', '/]*>\s*]*>/i', $content, 'Nested italic markup' ); $this->empty_paragraph_details( $details, $content ); $this->regex_details( $details, 'missing_alt', '/]*\salt=["\'])[^>]*>/i', $content, 'Image without an alt attribute' ); preg_match_all( '/]*>(.*?)<\/p>/is', $content, $paragraphs, PREG_OFFSET_CAPTURE ); $previous = null; foreach ( isset( $paragraphs[1] ) ? $paragraphs[1] : array() as $index => $paragraph ) { $text = $this->plain( $paragraph[0] ); $normal = strtolower( $text ); if ( '' !== $normal && null !== $previous && $normal === $previous ) { $selection = $this->expand_paragraph_block( $content, $paragraphs[0][ $index ][1], strlen( $paragraphs[0][ $index ][0] ) ); $this->add_detail( $details, 'duplicate_paragraph', 'Repeated paragraph: “' . $this->shorten( $text ) . '”', $selection['text'], $content, $selection['offset'], array( 'editor_action' => 'remove' ) ); } $previous = $normal; } preg_match_all( '/]*>(.*?)<\/h\1>/is', $content, $headings, PREG_OFFSET_CAPTURE ); $previous_level = null; foreach ( isset( $headings[1] ) ? $headings[1] : array() as $index => $level_match ) { $level = (int) $level_match[0]; $text = $this->plain( $headings[2][ $index ][0] ); $offset = $headings[0][ $index ][1]; if ( null !== $previous_level && $level > $previous_level + 1 ) { $this->add_detail( $details, 'heading_skip', 'Heading jumps from H' . $previous_level . ' to H' . $level . ': “' . $this->shorten( $text ) . '”', $headings[0][ $index ][0], $content, $offset, array( 'editor_action' => 'heading', 'current_level' => $level, 'target_level' => $previous_level + 1, ) ); } $previous_level = $level; } if ( ! empty( $headings[1] ) ) { $h1_seen = 0; foreach ( $headings[1] as $index => $level_match ) { if ( 1 === (int) $level_match[0] ) { $h1_seen++; if ( $h1_seen > 1 ) { $text = $this->plain( $headings[2][ $index ][0] ); $this->add_detail( $details, 'multiple_h1', 'Additional H1: “' . $this->shorten( $text ) . '”', $headings[0][ $index ][0], $content, $headings[0][ $index ][1], array( 'editor_action' => 'heading', 'current_level' => 1, 'target_level' => 2, ) ); } } } } preg_match_all( '/\sid=["\']([^"\']+)["\']/i', $content, $ids, PREG_OFFSET_CAPTURE ); $id_counts = array_count_values( array_map( function( $item ) { return $item[0]; }, isset( $ids[1] ) ? $ids[1] : array() ) ); $id_seen = array(); foreach ( isset( $ids[1] ) ? $ids[1] : array() as $index => $id ) { $id_seen[ $id[0] ] = isset( $id_seen[ $id[0] ] ) ? $id_seen[ $id[0] ] + 1 : 1; if ( isset( $id_counts[ $id[0] ] ) && $id_counts[ $id[0] ] > 1 && $id_seen[ $id[0] ] > 1 ) { $attribute = $ids[0][ $index ]; $this->add_detail( $details, 'duplicate_ids', 'Duplicate ID: #' . $id[0] . '. Keep the first occurrence; rename this one.', trim( $attribute[0] ), $content, $attribute[1], array( 'editor_action' => 'rename_id' ) ); } } preg_match_all( '/href=["\']#([^"\']+)["\']/i', $content, $anchors, PREG_OFFSET_CAPTURE ); $id_values = array_map( function( $item ) { return $item[0]; }, isset( $ids[1] ) ? $ids[1] : array() ); foreach ( isset( $anchors[1] ) ? $anchors[1] : array() as $anchor ) { if ( ! in_array( $anchor[0], $id_values, true ) ) { $this->add_detail( $details, 'broken_anchor', 'Link points to missing #' . $anchor[0], 'href="#' . $anchor[0] . '"', $content, $anchor[1] ); } } preg_match_all( '/]*target=["\']_blank["\'][^>]*>/i', $content, $links, PREG_OFFSET_CAPTURE ); foreach ( isset( $links[0] ) ? $links[0] : array() as $link ) { if ( ! preg_match( '/\brel=["\'][^"\']*\b(?:noopener|noreferrer)\b[^"\']*["\']/i', $link[0] ) ) { $this->add_detail( $details, 'unsafe_blank_link', 'New-tab link needs rel="noopener noreferrer"', $link[0], $content, $link[1] ); } } return apply_filters( 'owwcqc_issue_details', $details, $content ); } private function regex_details( &$details, $key, $pattern, $content, $label ) { preg_match_all( $pattern, $content, $matches, PREG_OFFSET_CAPTURE ); foreach ( isset( $matches[0] ) ? $matches[0] : array() as $match ) { $this->add_detail( $details, $key, $label . ': ' . $this->html_clue( $content, $match[1] ), $match[0], $content, $match[1] ); } } private function add_detail( &$details, $key, $message, $needle, $content, $offset, $extra = array() ) { if ( ! isset( $details[ $key ] ) ) { $details[ $key ] = array(); } $item = array( 'message' => $message, 'needle' => (string) $needle, 'line' => substr_count( substr( $content, 0, max( 0, (int) $offset ) ), "\n" ) + 1, 'offset' => max( 0, (int) $offset ), 'length' => strlen( (string) $needle ), ); $details[ $key ][] = array_merge( $item, (array) $extra ); } private function empty_paragraph_details( &$details, $content ) { preg_match_all( '/]*>\s*(?: | |\s|)*<\/p>/i', $content, $matches, PREG_OFFSET_CAPTURE ); foreach ( isset( $matches[0] ) ? $matches[0] : array() as $match ) { $selection = $this->expand_paragraph_block( $content, $match[1], strlen( $match[0] ) ); $this->add_detail( $details, 'empty_paragraph', 'Empty paragraph block: ' . $this->html_clue( $content, $match[1] ), $selection['text'], $content, $selection['offset'], array( 'editor_action' => 'remove' ) ); } } /** * Include a Gutenberg paragraph wrapper only when the matched paragraph is * the wrapper's complete content. This avoids removing a parent block. */ private function expand_paragraph_block( $content, $offset, $length ) { $selection = array( 'text' => substr( $content, $offset, $length ), 'offset' => (int) $offset, ); $before = substr( $content, 0, $offset ); $open = strrpos( $before, '', $open ); if ( false === $open_end || '' !== trim( substr( $content, $open_end + 3, $offset - ( $open_end + 3 ) ) ) ) { return $selection; } $match_end = $offset + $length; $close = strpos( $content, '', $match_end ); if ( false === $close || '' !== trim( substr( $content, $match_end, $close - $match_end ) ) ) { return $selection; } $close_end = $close + strlen( '' ); $selection['text'] = substr( $content, $open, $close_end - $open ); $selection['offset'] = $open; return $selection; } private function plain( $html ) { return trim( preg_replace( '/\s+/', ' ', wp_strip_all_tags( html_entity_decode( (string) $html, ENT_QUOTES, 'UTF-8' ) ) ) ); } private function shorten( $text, $limit = 100 ) { $text = trim( preg_replace( '/\s+/', ' ', (string) $text ) ); return strlen( $text ) > $limit ? substr( $text, 0, $limit - 1 ) . '…' : $text; } private function html_clue( $content, $offset ) { $start = max( 0, (int) $offset - 45 ); return 'HTML near line ' . ( substr_count( substr( $content, 0, $offset ), "\n" ) + 1 ) . ' — ' . $this->shorten( substr( $content, $start, 130 ), 120 ); } private function match_count( &$issues, $key, $pattern, $content ) { preg_match_all( $pattern, $content, $matches ); $this->set( $issues, $key, count( $matches[0] ) ); } private function set( &$issues, $key, $count ) { if ( $count > 0 ) { $issues[ $key ] = (int) $count; } } }