3, 'max_paragraphs' => 0, 'skip_first_paragraphs' => 0, 'skip_last_paragraphs' => 1, 'min_words_above' => 0, 'min_paragraph_words' => 5, 'paragraph_tags' => 'p', 'count_inside_blockquote' => false, ]; private $settings; public function __construct(array $overrides = []) { $this->settings = self::settings($overrides); } public static function settings(array $overrides = []) { $out = []; foreach (self::DEFAULTS as $key => $default) { $value = get_option('adghost_' . $key, $default); if (array_key_exists($key, $overrides) && $overrides[$key] !== '' && $overrides[$key] !== null) { $value = $overrides[$key]; } $out[$key] = ($key === 'paragraph_tags') ? (string) $value : (($key === 'count_inside_blockquote') ? filter_var($value, FILTER_VALIDATE_BOOLEAN) : absint($value)); } return apply_filters('adghost_paragraph_rules', $out, $overrides); } public function all() { return $this->settings; } public static function count_tags(?array $settings = null) { $raw = $settings['paragraph_tags'] ?? get_option('adghost_paragraph_tags', self::DEFAULTS['paragraph_tags']); $tags = []; foreach (preg_split('/[\s,]+/', str_replace(['<', '>'], '', (string) $raw)) as $tag) { $tag = strtolower(trim($tag)); if ($tag !== '' && preg_match('/^[a-z][a-z0-9]*$/', $tag)) { $tags[] = $tag; } } return $tags ?: ['p']; } public function content_gate($content) { $total = $this->count_paragraphs($content); $min = $this->settings['min_paragraphs']; if ($min > 0 && $total < $min) { return sprintf( 'Post has %d %s; at least %d required', $total, $total === 1 ? 'paragraph' : 'paragraphs', $min ); } $max = $this->settings['max_paragraphs']; if ($max > 0 && $total > $max) { return sprintf('Post has %d paragraphs; the maximum is %d', $total, $max); } return null; } public function count_paragraphs($content) { return count($this->paragraph_ends($content)); } public function paragraph_ends($content) { $tags = self::count_tags($this->settings); $alt = implode('|', array_map(function ($t) { return preg_quote($t, '/'); }, $tags)); if (!preg_match_all('/<\/(?:' . $alt . ')>/i', $content, $m, PREG_OFFSET_CAPTURE)) { return self::block_ends($content); } preg_match_all('/<(?:' . $alt . ')\b[^>]*>/i', $content, $opens, PREG_OFFSET_CAPTURE); $open_at = array_column($opens[0], 1); $ends = []; $o = 0; $last_open = null; $skip_ranges = $this->settings['count_inside_blockquote'] ? [] : self::element_ranges($content, ['blockquote', 'figure', 'li']); foreach ($m[0] as $match) { $end = $match[1] + strlen($match[0]); foreach ($skip_ranges as [$from, $to]) { if ($match[1] > $from && $match[1] < $to) { continue 2; } } while ($o < count($open_at) && $open_at[$o] < $match[1]) { $last_open = $open_at[$o]; $o++; } if ($last_open !== null && !self::has_text(substr($content, $last_open, $match[1] - $last_open))) { continue; } $ends[] = $end; } return $ends; } private static function has_text($html) { $text = html_entity_decode(wp_strip_all_tags((string) $html), ENT_QUOTES | ENT_HTML5, 'UTF-8'); return trim(str_replace("\xC2\xA0", ' ', $text)) !== ''; } public static function block_ends($content) { if (preg_match_all('/<(\/?)(div|section|article)\b[^>]*>/i', $content, $m, PREG_OFFSET_CAPTURE)) { $by_depth = []; $depth = 0; $opened = []; foreach ($m[0] as $i => $tag) { if ($m[1][$i][0] === '') { $opened[$depth] = $tag[1]; $depth++; continue; } $depth = max(0, $depth - 1); $start = $opened[$depth] ?? 0; $end = $tag[1] + strlen($tag[0]); if (AdGhost_Content_Analyzer::count_words(substr($content, $start, $end - $start)) >= 8) { $by_depth[$depth][] = $end; } } ksort($by_depth); foreach ($by_depth as $ends) { if (count($ends) >= 3) { return $ends; } } } if (preg_match_all('/\s*(?: |\s)*/i', $content, $m, PREG_OFFSET_CAPTURE)) { $ends = []; $prev = 0; foreach ($m[0] as $match) { $end = $match[1] + strlen($match[0]); if (AdGhost_Content_Analyzer::count_words(substr($content, $prev, $match[1] - $prev)) >= 8) { $ends[] = $end; } $prev = $end; } if (count($ends) >= 2) { return $ends; } } return []; } public function filter_points(array $points, $content, ?array &$rejected = null) { $collect = is_array($rejected); $total = $this->count_paragraphs($content); $skip_first = $this->settings['skip_first_paragraphs']; $skip_last = $this->settings['skip_last_paragraphs']; $min_above = $this->settings['min_words_above']; $min_words = $this->settings['min_paragraph_words']; $out = []; foreach ($points as $p) { $n = (int) ($p['paragraph_number'] ?? 0); $reason = null; if ($skip_first > 0 && $n <= $skip_first) { $reason = sprintf('Within the first %d %s of the post', $skip_first, $skip_first === 1 ? 'paragraph' : 'paragraphs'); } elseif ($skip_last > 0 && $n > ($total - $skip_last)) { $reason = sprintf('Within the last %d %s of the post', $skip_last, $skip_last === 1 ? 'paragraph' : 'paragraphs'); } elseif ($min_words > 0 && ($p['type'] ?? 'paragraph') === 'paragraph' && isset($p['word_count']) && $p['word_count'] < $min_words) { $reason = sprintf('Paragraph has %d words; %d required', (int) $p['word_count'], $min_words); } elseif ($min_above > 0) { $above = $this->words_above($content, (int) ($p['offset'] ?? 0)); if ($above < $min_above) { $reason = sprintf('Only %d words above this point; %d required', $above, $min_above); } } if ($reason !== null) { if ($collect) { $rejected[$p['id']] = $reason; } continue; } $out[] = $p; } return $out; } private function words_above($content, $offset) { return AdGhost_Content_Analyzer::count_words(substr($content, 0, $offset)); } public static function element_ranges($content, array $tags) { $ranges = []; foreach ($tags as $tag) { if (!preg_match_all('/<(\/?)' . preg_quote($tag, '/') . '\b[^>]*>/i', $content, $m, PREG_OFFSET_CAPTURE)) { continue; } $depth = 0; $start = 0; foreach ($m[0] as $i => $t) { if ($m[1][$i][0] === '') { if ($depth++ === 0) { $start = $t[1]; } } elseif ($depth > 0 && --$depth === 0) { $ranges[] = [$start, $t[1] + strlen($t[0])]; } } if ($depth > 0) { $ranges[] = [$start, strlen($content)]; } } return $ranges; } }