safe_rules; } public function can_fix_one( $rule ) { return in_array( $rule, array_merge( $this->safe_rules, $this->reviewed_rules ), true ); } public function fixable_issues( $issues ) { return array_intersect_key( (array) $issues, array_flip( $this->safe_rules ) ); } public function fixable_total( $issues ) { return array_sum( array_map( 'intval', $this->fixable_issues( $issues ) ) ); } public function fix_one( $content, $rule, $occurrence ) { $content = (string) $content; $occurrence = max( 0, (int) $occurrence ); if ( ! $this->can_fix_one( $rule ) ) { return array( 'content' => $content, 'changed' => false ); } if ( 'empty_paragraph' === $rule ) { $pattern = '/(?:\s*)?]*>\s*(?: | |\s|)*<\/p>\s*(?:)?/is'; return $this->replace_occurrence( $content, $pattern, $occurrence, function() { return ''; } ); } if ( 'duplicate_ids' === $rule ) { return $this->rename_duplicate_id( $content, $occurrence ); } if ( 'heading_skip' === $rule || 'multiple_h1' === $rule ) { return $this->repair_heading( $content, $rule, $occurrence ); } if ( 'nested_strong' === $rule || 'nested_em' === $rule ) { $tag = 'nested_strong' === $rule ? 'strong' : 'em'; $pattern = '/<' . $tag . '\\b([^>]*)>\\s*<' . $tag . '\\b[^>]*>(.*?)<\\/' . $tag . '>\\s*<\\/' . $tag . '>/is'; return $this->replace_occurrence( $content, $pattern, $occurrence, function( $match ) use ( $tag ) { return '<' . $tag . $match[1] . '>' . $match[2] . ''; } ); } if ( 'missing_alt' === $rule ) { return $this->replace_occurrence( $content, '/]*\\salt=["\'])[^>]*>/i', $occurrence, function( $match ) { return preg_replace( '/\\s*\\/?\\s*>$/', ' alt=""$0', $match[0], 1 ); } ); } $current = 0; $changed = false; $updated = preg_replace_callback( '/]*target=["\']_blank["\'][^>]*>/i', function( $match ) use ( &$current, &$changed, $occurrence ) { $tag = $match[0]; if ( preg_match( '/\\brel=["\'][^"\']*\\b(?:noopener|noreferrer)\\b[^"\']*["\']/i', $tag ) ) { return $tag; } if ( $current++ !== $occurrence ) { return $tag; } $changed = true; if ( preg_match( '/\\brel=(["\'])(.*?)\\1/i', $tag, $rel ) ) { $tokens = array_filter( preg_split( '/\\s+/', strtolower( trim( $rel[2] ) ) ) ); foreach ( array( 'noopener', 'noreferrer' ) as $needed ) { if ( ! in_array( $needed, $tokens, true ) ) { $tokens[] = $needed; } } return preg_replace( '/\\brel=(["\'])(.*?)\\1/i', 'rel="' . implode( ' ', $tokens ) . '"', $tag, 1 ); } return preg_replace( '/>$/', ' rel="noopener noreferrer">', $tag, 1 ); }, $content ); return array( 'content' => $updated, 'changed' => $changed ); } private function rename_duplicate_id( $content, $wanted ) { preg_match_all( '/\sid=(["\'])([^"\']+)\1/i', $content, $matches, PREG_OFFSET_CAPTURE ); $counts = array_count_values( array_map( function( $item ) { return $item[0]; }, $matches[2] ) ); $seen = array(); $candidate = 0; $changed = false; $updated = preg_replace_callback( '/\sid=(["\'])([^"\']+)\1/i', function( $match ) use ( &$seen, &$candidate, &$changed, $wanted, $counts, $content ) { $id = $match[2]; $seen[ $id ] = isset( $seen[ $id ] ) ? $seen[ $id ] + 1 : 1; if ( empty( $counts[ $id ] ) || $counts[ $id ] < 2 || 1 === $seen[ $id ] ) { return $match[0]; } if ( $candidate++ !== $wanted ) { return $match[0]; } if ( preg_match( '/(?:url\(\s*#|xlink:href=["\']#)' . preg_quote( $id, '/' ) . '/i', $content ) ) { return $match[0]; } $suffix = $seen[ $id ]; do { $new_id = $id . '-' . $suffix++; } while ( preg_match( '/\sid=["\']' . preg_quote( $new_id, '/' ) . '["\']/i', $content ) ); $changed = true; return ' id=' . $match[1] . $new_id . $match[1]; }, $content ); return array( 'content' => $updated, 'changed' => $changed ); } private function repair_heading( $content, $rule, $wanted ) { $previous = null; $candidate = 0; $h1_seen = 0; $changed = false; $updated = preg_replace_callback( '/]*)>(.*?)<\/h\1>/is', function( $match ) use ( &$previous, &$candidate, &$h1_seen, &$changed, $rule, $wanted ) { $level = (int) $match[1]; $target = 0; if ( 'heading_skip' === $rule && null !== $previous && $level > $previous + 1 ) { $target = $previous + 1; } if ( 'multiple_h1' === $rule && 1 === $level && ++$h1_seen > 1 ) { $target = 2; } if ( 'multiple_h1' === $rule && 1 !== $level ) { /* Keep the H1 counter unchanged. */ } $previous = $level; if ( ! $target || $candidate++ !== $wanted ) { return $match[0]; } $changed = true; return '' . $match[3] . ''; }, $content ); return array( 'content' => $updated, 'changed' => $changed ); } private function replace_occurrence( $content, $pattern, $wanted, $callback ) { $current = 0; $changed = false; $updated = preg_replace_callback( $pattern, function( $match ) use ( &$current, &$changed, $wanted, $callback ) { if ( $current++ !== $wanted ) { return $match[0]; } $replacement = call_user_func( $callback, $match ); $changed = $replacement !== $match[0]; return $replacement; }, $content ); return array( 'content' => $updated, 'changed' => $changed ); } /** * Applies only attribute/inline-wrapper fixes. WordPress block comments, * element order, paragraph blocks, headings, IDs and anchors are untouched. */ public function fix( $content ) { $content = (string) $content; $changes = array(); $content = $this->collapse_nested_tag( $content, 'strong', 'nested_strong', $changes ); $content = $this->collapse_nested_tag( $content, 'em', 'nested_em', $changes ); $content = preg_replace_callback( '/]*>/i', function ( $match ) use ( &$changes ) { if ( preg_match( '/\salt\s*=/i', $match[0] ) ) { return $match[0]; } $changes['missing_alt'] = isset( $changes['missing_alt'] ) ? $changes['missing_alt'] + 1 : 1; return preg_replace( '/\s*\/?\s*>$/', ' alt=""$0', $match[0], 1 ); }, $content ); $content = preg_replace_callback( '/]*target=["\']_blank["\'][^>]*>/i', function ( $match ) use ( &$changes ) { $tag = $match[0]; if ( preg_match( '/\brel=(["\'])(.*?)\1/i', $tag, $rel ) ) { $tokens = preg_split( '/\s+/', trim( $rel[2] ) ); $tokens = array_filter( array_map( 'strtolower', $tokens ) ); $updated = false; foreach ( array( 'noopener', 'noreferrer' ) as $needed ) { if ( ! in_array( $needed, $tokens, true ) ) { $tokens[] = $needed; $updated = true; } } if ( ! $updated ) { return $tag; } $tag = preg_replace( '/\brel=(["\'])(.*?)\1/i', 'rel="' . implode( ' ', $tokens ) . '"', $tag, 1 ); } else { $tag = preg_replace( '/>$/', ' rel="noopener noreferrer">', $tag, 1 ); } $changes['unsafe_blank_link'] = isset( $changes['unsafe_blank_link'] ) ? $changes['unsafe_blank_link'] + 1 : 1; return $tag; }, $content ); return array( 'content' => $content, 'changes' => $changes ); } private function collapse_nested_tag( $content, $tag, $rule, &$changes ) { $pattern = '/<' . $tag . '\b([^>]*)>\s*<' . $tag . '\b[^>]*>(.*?)<\/' . $tag . '>\s*<\/' . $tag . '>/is'; do { $content = preg_replace_callback( $pattern, function ( $match ) use ( $tag, $rule, &$changes ) { $changes[ $rule ] = isset( $changes[ $rule ] ) ? $changes[ $rule ] + 1 : 1; return '<' . $tag . $match[1] . '>' . $match[2] . ''; }, $content, -1, $count ); } while ( $count > 0 ); return $content; } }