'"', "\u{201C}" => "\u{201D}", // “ ” "'" => "'", "\u{2018}" => "\u{2019}", // ‘ ’ "\u{00AB}" => "\u{00BB}", // « » "\u{201E}" => "\u{201C}", // „ “ ); for ($pass = 0; $pass < 2; $pass++) { $stripped = false; foreach ($pairs as $open => $close) { if (mb_substr($text, 0, 1) === $open && mb_substr($text, -1) === $close && mb_strlen($text) > 2) { $text = trim(mb_substr($text, 1, mb_strlen($text) - 2)); $stripped = true; break; } } if (!$stripped) { break; } } return $text; } /** * v2.6.7: comparable plain text — entities decoded, quotes folded, every dash run * (-, --, ---, ‐ ‒ – — ―, minus) with its surrounding spaces → "-", … → "...", * NBSP → space, whitespace collapsed. Twin of optimize.js canonicalMatchText(). */ public function canonical_match_text($text) { $t = html_entity_decode((string) $text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); $t = str_replace(array("\u{00A0}", "\u{2007}", "\u{202F}"), ' ', $t); $t = str_replace(array("\u{2018}", "\u{2019}", "\u{201A}", "\u{201B}", "\u{2032}"), "'", $t); $t = str_replace(array("\u{201C}", "\u{201D}", "\u{201E}", "\u{2033}"), '"', $t); $t = preg_replace('/\s*(?:-{1,3}|[\x{2010}-\x{2015}\x{2212}])\s*/u', '-', $t); $t = str_replace("\u{2026}", '...', $t); return trim(preg_replace('/\s+/u', ' ', $t)); } /** * v2.6.7: regex body (use with /iu) that finds $text in post HTML whatever form the * post uses: the AI quotes the post as WordPress DISPLAYS it (wptexturize turns * "--" into "–", straight into curly quotes, adds entities) while post_content * keeps the raw text — so 2 of 3 rewrites on a post with "(1918 – 2013)" and * "towards -- unity" were skipped. Twin of optimize.js buildTolerantPattern(). */ public function build_tolerant_pattern($text) { $ws = '(?:\s| | |�*a0;|\x{00A0})'; $dash = $ws . '*(?:-{1,3}|[\x{2010}-\x{2015}\x{2212}]|&(?:ndash|mdash|minus|#821[0-2]|#x201[0-5]|#8722);)' . $ws . '*'; $sq = "(?:['\x{2018}\x{2019}\x{201A}\x{201B}\x{2032}]|&(?:#0*39|#821[6-9]|#x201[89ab]|apos|lsquo|rsquo|sbquo);)"; $dq = '(?:["\x{201C}\x{201D}\x{201E}\x{2033}]|&(?:quot|#0*34|#822[0-2]|#x201[cde]|ldquo|rdquo|bdquo);)'; $amp = '(?:&|�*38;|&)'; $ell = '(?:\.\.\.|\x{2026}|…|…)'; $t = $this->canonical_match_text($text); $chars = preg_split('//u', $t, -1, PREG_SPLIT_NO_EMPTY); $out = ''; for ($i = 0, $n = count($chars); $i < $n; $i++) { $c = $chars[$i]; if ($c === '.' && $i + 2 < $n && $chars[$i + 1] === '.' && $chars[$i + 2] === '.') { $out .= $ell; $i += 2; } elseif ($c === ' ') { $out .= $ws . '+'; } elseif ($c === '-') { $out .= $dash; } elseif ($c === "'") { $out .= $sq; } elseif ($c === '"') { $out .= $dq; } elseif ($c === '&') { $out .= $amp; } elseif (strlen($c) > 1) { $out .= '(?:' . preg_quote($c, '/') . '|&#' . mb_ord($c, 'UTF-8') . ';)'; } else { $out .= preg_quote($c, '/'); } } return $out; } /** * Replace $source with $rewrite inside $content. Matching ladder: * whole source → first ~50 chars extended to sentence end — both through * build_tolerant_pattern() and gated by sentence-boundary and blockquote guards. * NEVER appends or inserts: on no safe match, content is returned unchanged with * applied=false and a reason code (v2.6.7): no_source, not_found, formatting, * blockquote, mid_sentence_start, mid_sentence_end. * * @return array {content: string, applied: bool, method: string, reason: string} */ public function apply_source_replacement($content, $source, $rewrite) { $no = function ($why, $reason) use ($content) { return array('content' => $content, 'applied' => false, 'method' => $why, 'reason' => $reason); }; // v2.5.6: strip a matched wrapping-quote pair (grammar sources arrive quoted). $source_text = trim(preg_replace('/\s+/', ' ', $this->strip_wrapping_quotes($source))); $rewrite = $this->strip_wrapping_quotes($rewrite); if ($this->canonical_match_text($source_text) === '' || $rewrite === '') { return $no('empty source or rewrite', 'no_source'); } $plain = mb_strtolower($this->canonical_match_text(preg_replace('/<[^>]+>/', ' ', $content))); $want = $this->canonical_match_text($source_text); // v2.6.9: exactly one copy of the source, else a named skip (JS twin: replaceSourceInHtml). $seen = $this->count_occurrences($plain, $want); if ($seen > 1) { return $no('source text appears more than once', 'ambiguous'); } if ($seen === 1) { // Rung 1: the whole source. $pattern = '/' . $this->build_tolerant_pattern($want) . '/iu'; $method = 'exact'; } else { // Rung 2: fuzzy — first ~50 chars (whole words), extended to the sentence end. // v2.6.9: one-sentence sources only (a paragraph source left its other sentences // behind next to the rewrite — the duplicated intro on ffl.org post 35249). if (count($this->text_sentences($want)) > 1) { return $no('source text not found in content', 'not_found'); } $words = preg_split('/\s+/', mb_substr($want, 0, 50), -1, PREG_SPLIT_NO_EMPTY); if (mb_strlen($want) > 50 && count($words) > 3) { array_pop($words); // possibly cut word } $short = implode(' ', $words); if (count($words) >= 4 && $this->count_occurrences($plain, $short) > 1) { return $no('source text appears more than once', 'ambiguous'); } if (count($words) < 4 || strpos($plain, mb_strtolower($short)) === false) { return $no('source text not found in content', 'not_found'); } $pattern = '/' . $this->build_tolerant_pattern($short) . '[^.!?<]{0,200}[.!?]?/iu'; $method = 'fuzzy'; } if (!preg_match($pattern, $content, $m, PREG_OFFSET_CAPTURE)) { return $no('text found, but formatting inside it prevents a safe replacement', 'formatting'); } $blocked = $this->replacement_block_reason($content, $m[0][1], strlen($m[0][0])); if ($blocked !== '') { return $no($method . ' match found but blocked by safety guard (' . $blocked . ')', $blocked); } return array( 'content' => substr_replace($content, $rewrite, $m[0][1], strlen($m[0][0])), 'applied' => true, 'method' => $method, 'reason' => '', ); } /** * v2.5.5 guard (PHP port of optimize.js contentBlockReason()): '' when safe, else * blockquote / mid_sentence_start / mid_sentence_end. v2.6.7: the boundary * characters are read after entity decoding (".”", a following " "). */ private function replacement_block_reason($content, $offset, $length) { $before = substr($content, 0, $offset); $matched = substr($content, $offset, $length); $after = substr($content, $offset + $length); $decode = function ($t) { return html_entity_decode($t, ENT_QUOTES | ENT_HTML5, 'UTF-8'); }; $trim_ws = function ($t, $side) { $re = $side === 'end' ? '/[\s\x{00A0}]+$/u' : '/^[\s\x{00A0}]+/u'; $r = preg_replace($re, '', $t); return $r === null ? "\0invalid" : $r; // regex failure must never read as a boundary }; // 1) Never rewrite inside
— quoted material is sacrosanct. if (preg_match_all('/
preg_match_all('/<\/blockquote>/i', $before)) { return 'blockquote'; } // 2) Match must START at a sentence/paragraph boundary. // mb_strcut: a byte slice could split a UTF-8 character, the /u regex would then // fail, and an empty result would wrongly read as "at a boundary". $prev = $trim_ws($decode(mb_strcut($before, max(0, strlen($before) - 60), null, 'UTF-8')), 'end'); if (!(trim($before) === '' || $prev === '' || preg_match('/[>.!?:]["\'\x{201C}\x{201D}\x{2018}\x{2019}]?$/u', $prev))) { return 'mid_sentence_start'; } // 3) Match must END at a sentence boundary (or be followed by a tag/end). $tail = $trim_ws($decode(mb_strcut($matched, max(0, strlen($matched) - 60), null, 'UTF-8')), 'end'); $next = $trim_ws($decode(mb_strcut($after, 0, 60, 'UTF-8')), 'start'); if (!(preg_match('/[.!?]["\'\x{201C}\x{201D}\x{2018}\x{2019}]?$/u', $tail) || $next === '' || $next[0] === '<')) { return 'mid_sentence_end'; } return ''; } /** Case-insensitive count of $needle in $hay (canonical text). */ public function count_occurrences($hay, $needle) { $needle = mb_strtolower((string) $needle); return $needle === '' ? 0 : substr_count(mb_strtolower((string) $hay), $needle); } /** Comparable plain text of HTML: tags → spaces, canonical, lower case. */ public function plain_canon($html) { return mb_strtolower($this->canonical_match_text(preg_replace('/<[^>]+>/', ' ', (string) $html))); } /** Letters/digits only. */ public function bare_text($text) { return trim(preg_replace('/[^\p{L}\p{N}]+/u', ' ', $this->plain_canon($text))); } public function text_word_count($text) { $bare = $this->bare_text($text); return $bare === '' ? 0 : count(explode(' ', $bare)); } public function text_sentences($text) { $parts = preg_split('/(?<=[.!?])["\')\]]?\s+/u', $this->plain_canon($text)); return array_values(array_filter(array_map('trim', (array) $parts), function ($x) { return preg_match('/[\p{L}\p{N}]/u', $x); })); } /** Reviewer notes / commentary in a rewrite ('' = none). A phrase also in the source is the author's text. */ public function rewrite_meta_problem($after, $before) { $a = $this->plain_canon($after); $b = $this->plain_canon($before); if (preg_match('/^(corrected|capitali[sz]ed|fixed|changed|improved|added|removed|updated)\b/i', $a, $m) && strpos($b, mb_strtolower($m[1])) !== 0) { return 'meta_text'; } if (preg_match('/suggested rewrite|sentence starts|for clarity|grammar fix/i', $a, $m) && strpos($b, mb_strtolower($m[0])) === false) { return 'meta_text'; } foreach ($this->text_sentences($after) as $sentence) { if (preg_match('/^(this paragraph|this condensed version|consider adding|this version|note:|i have (?:rewritten|revised|condensed|changed|added|removed|made|kept|improved|corrected|updated|adjusted|tightened|simplified|restructured|shortened|clarified|replaced|merged|reworded|edited|expanded|preserved|maintained))/i', $sentence) && strpos($b, rtrim($sentence, '.!?')) === false) { return 'commentary'; } } return ''; } /** The rewrite is (a copy of) its reason. */ public function resembles_reason($after, $reason) { $a = $this->bare_text($after); $r = $this->bare_text(preg_replace('/^[a-z_]+\s+-\s+/i', '', (string) $reason)); if ($a === '' || $r === '') { return false; } if ($a === $r) { return true; } $aw = explode(' ', $a); $rw = explode(' ', $r); if (count($aw) >= 3 && (strpos($r, $a) !== false || (count($rw) >= 3 && strpos($a, $r) !== false && count($rw) >= count($aw) * 0.6))) { return true; } $au = array_unique($aw); $ru = array_unique($rw); $union = count(array_unique(array_merge($au, $ru))); return $union > 0 && count(array_intersect($au, $ru)) / $union >= 0.8; } public function page_headings($html) { $out = array(); if (preg_match_all('/]*>(.*?)<\/h[1-6]>/is', (string) $html, $m)) { foreach ($m[1] as $h) { $t = $this->bare_text($h); if ($t !== '') { $out[] = $t; } } } return $out; } public function page_paragraphs($html) { $html = preg_replace('/]*>.*?<\/h[1-6]>/is', "\n\n", (string) $html); $out = array(); foreach (preg_split('/<\/(?:p|div|li|blockquote|td|section)>||\n\s*\n/i', $html) as $part) { $t = $this->plain_canon($part); if ($t !== '') { $out[] = $t; } } return $out; } private function shingles($text) { $w = array_values(array_filter(explode(' ', $this->bare_text($text)), 'strlen')); $out = array(); for ($i = 0; $i + 2 < count($w); $i++) { $out[$w[$i] . ' ' . $w[$i + 1] . ' ' . $w[$i + 2]] = true; } return array_keys($out); } public function heading_problem($after, $before, array $ctx) { $h = $this->bare_text($after); if (count(explode(' ', $h)) < 3) { return 'heading_short'; } $own = $this->bare_text($before); foreach ($this->page_headings($ctx['html'] ?? '') as $x) { if ($x === $h && $x !== $own) { return 'heading_duplicate'; } } foreach ($this->page_paragraphs($ctx['html'] ?? '') as $p) { if (count(explode(' ', $h)) >= 6 && strpos(' ' . $this->bare_text($p) . ' ', ' ' . $h . ' ') !== false) { return 'heading_copies_body'; } foreach ($this->text_sentences($p) as $sentence) { if ($this->bare_text($sentence) === $h) { return 'heading_copies_body'; } } } return ''; } public function faq_answer_problem($answer, array $ctx, $reason = '') { $a = $this->bare_text($answer); if ($a === '') { return 'incomplete'; } if ($this->rewrite_meta_problem($answer, '') !== '' || ($reason !== '' && $this->resembles_reason($answer, $reason))) { return 'meta_text'; } foreach (array_merge(array($this->bare_text($ctx['title'] ?? '')), $this->page_headings($ctx['html'] ?? '')) as $t) { if ($t !== '' && $t === $a) { return 'faq_title'; } } if (count($this->text_sentences($answer)) < 2) { return 'faq_short'; } $sh = $this->shingles($answer); if ($sh) { foreach ($this->page_paragraphs($ctx['html'] ?? '') as $p) { if (count(array_intersect($sh, $this->shingles($p))) / count($sh) > 0.6) { return 'faq_repeats'; } } } return ''; } /** After one replacement: the rewrite one more time than before (6+ words: exactly once), the source nowhere outside it. '' or 'verify_failed'. */ public function verify_replacement($old_html, $new_html, $source, $rewrite) { $was = $this->plain_canon($old_html); $now = $this->plain_canon($new_html); $rw = $this->plain_canon($this->strip_wrapping_quotes($rewrite)); $src = $this->plain_canon($this->strip_wrapping_quotes($source)); if ($rw === '') { return 'verify_failed'; } // The rewrite is counted case-sensitively (a case-only grammar fix must not count its // own source), as written or with the capital the matcher may give it. $rw_case = $this->canonical_match_text(preg_replace('/<[^>]+>/', ' ', $this->strip_wrapping_quotes($rewrite))); $rw_up = mb_strtoupper(mb_substr($rw_case, 0, 1)) . mb_substr($rw_case, 1); $count_rw = function ($html) use ($rw_case, $rw_up) { $t = $this->canonical_match_text(preg_replace('/<[^>]+>/', ' ', (string) $html)); return substr_count($t, $rw_case) + ($rw_up !== $rw_case ? substr_count($t, $rw_up) : 0); }; $rw_now = $count_rw($new_html); if ($rw_now !== $count_rw($old_html) + 1 || ($this->text_word_count($rw) >= 6 && $rw_now !== 1)) { return 'verify_failed'; } if ($src !== '' && $this->count_occurrences($now, $src) > max(0, $this->count_occurrences($was, $src) - 1) + $this->count_occurrences($rw, $src) * $rw_now) { return 'verify_failed'; } return ''; } /** Paragraphs over 20 words that appear twice in $new_html but not already in $old_html. */ public function new_duplicate_paragraphs($old_html, $new_html) { $dupes = function ($html) { $seen = array(); $out = array(); foreach ($this->page_paragraphs($html) as $p) { $k = $this->bare_text($p); if (count(explode(' ', $k)) <= 20) { continue; } if (isset($seen[$k])) { $out[$k] = $p; } else { $seen[$k] = true; } } return $out; }; return array_values(array_diff_key($dupes($new_html), $dupes($old_html))); } /** * v2.5.5: normalized word-set for FAQ question similarity. */ public function faq_question_word_set($question) { $q = strtolower(wp_strip_all_tags((string) $question)); $q = preg_replace('/^\s*q:\s*/i', '', $q); $q = preg_replace('/[^\p{L}\p{N}\s]/u', ' ', $q); $words = preg_split('/\s+/', trim($q), -1, PREG_SPLIT_NO_EMPTY); $stop = array('a','an','the','is','are','was','were','do','does','did','how','what','why','when','who','which','of','in','on','to','for','and','or','his','her','their','it','its','this','that','you','your','can','be'); $set = array(); foreach ($words as $w) { if (!in_array($w, $stop, true)) { $set[$w] = true; } } return array_keys($set); } /** * v2.5.5: is $question a near-duplicate of any word-set in $existing_sets? * Jaccard overlap on stopword-free word sets — catches rewordings that * differ by a word or two ("…affect his approach" vs "…shape his approach"). */ /** * v2.6.1: count matching words between two sets, treating inflections as * equal via a >=6-char shared prefix ("accommodation"/"accommodating", * "danger"/"dangerous"). Exact-token intersection missed real rewordings. */ private function count_word_overlap(array $a, array $b) { $matched = 0; $used = array(); foreach ($a as $wa) { foreach ($b as $bi => $wb) { if (isset($used[$bi])) { continue; } if ($wa === $wb || (mb_strlen($wa) >= 6 && mb_strlen($wb) >= 6 && mb_substr($wa, 0, 6) === mb_substr($wb, 0, 6))) { $matched++; $used[$bi] = true; break; } } } return $matched; } public function faq_question_is_duplicate($question, array $existing_sets, $threshold = 0.55) { $set = $this->faq_question_word_set($question); if (empty($set)) { return true; // empty/garbage question — never add } foreach ($existing_sets as $other) { if (empty($other)) { continue; } // v2.6.1: prefix-tolerant overlap instead of exact array_intersect — // inflected rewordings ("accommodation"/"accommodating") now match. $intersect = $this->count_word_overlap($set, $other); $union = count($set) + count($other) - $intersect; // v2.5.6: threshold 0.7 → 0.55 (real reworded pairs slipped through); // plus a containment test — a question whose content words are mostly // a subset of an existing one is a rephrase even when the longer // question drags the Jaccard union down. $containment = $intersect / min(count($set), count($other)); if (($union > 0 && ($intersect / $union) >= $threshold) || $containment >= 0.8) { return true; } } return false; } /** * v2.5.5: pull existing FAQ-style questions (h3/h4 headings and bolded * question lines) out of post content, as normalized word-sets. */ public function extract_existing_faq_question_sets($content) { $sets = array(); if (preg_match_all('/]*>(.*?)<\/h[34]>/is', (string) $content, $m)) { foreach ($m[1] as $q) { $sets[] = $this->faq_question_word_set($q); } } if (preg_match_all('/<(?:strong|b)>\s*(?:Q:)?\s*([^<]{10,200}\?)\s*<\/(?:strong|b)>/i', (string) $content, $m2)) { foreach ($m2[1] as $q) { $sets[] = $this->faq_question_word_set($q); } } return array_values(array_filter($sets)); } /** * v2.5.7: remove the post's FAQ block before building content/grammar prompts. * FAQ sections are always h2-anchored in plugin-touched posts (same regex the * v2.5.5 apply path uses to locate them) — content rewrites must never see or * target FAQ answers; the faq group owns that block. */ public function strip_faq_section($content) { if (!preg_match('/]*>[^<]*\b(faq|frequently asked questions)\b[^<]*<\/h2>/i', (string) $content, $m, PREG_OFFSET_CAPTURE)) { return $content; } $faq_start = $m[0][1]; $after_heading = $faq_start + strlen($m[0][0]); $faq_end = strlen($content); if (preg_match('/]*>/i', $content, $nm, PREG_OFFSET_CAPTURE, $after_heading)) { $faq_end = $nm[0][1]; } return substr($content, 0, $faq_start) . substr($content, $faq_end); } /** * v2.5.9: deterministic POV backstop — did a rewrite drop the author's * first person? Checkable without trusting the model or the fingerprint: * source contains first-person markers, rewrite contains none → the item * converted the voice and must be rejected. */ public function rewrite_drops_first_person($source, $rewrite) { $fp = '/\b(i|i\'m|i\'ve|i\'d|i\'ll|me|my|mine|myself|we|we\'re|we\'ve|our|ours|us|ourselves)\b/i'; $source_has = preg_match($fp, wp_strip_all_tags((string) $source)); if (!$source_has) { return false; // nothing to preserve } return !preg_match($fp, wp_strip_all_tags((string) $rewrite)); } /** * v2.5.7: voice-constraint prompt block from the style fingerprint. The v2.5.0 * section prompts (built from fast mode) dropped ALL voice information — the * fingerprint was computed and never injected, so models rewrote first-person * essays as third-person Q&A listicles. */ public function build_voice_constraints($style_fingerprint) { $person = isset($style_fingerprint['person']) ? $style_fingerprint['person'] : 'third'; $tone = isset($style_fingerprint['tone']) ? $style_fingerprint['tone'] : 'informative'; $formality = isset($style_fingerprint['formality']) ? $style_fingerprint['formality'] : 'moderate'; $avg_len = !empty($style_fingerprint['avg_sentence_len']) ? round($style_fingerprint['avg_sentence_len']) : null; $block = "🛑 AUTHOR VOICE — HARD CONSTRAINTS (violations are rejected outright):\n"; if ($person === 'first') { $block .= "- This post is written in the FIRST PERSON about the author's own life. Every Rewrite MUST stay in first person. NEVER convert \"I\"/\"my\"/\"me\" into \"the author\", \"he\", \"she\", or any third-person reference. A rewrite that says \"the author\" is WRONG and will be discarded.\n"; } elseif ($person === 'second') { $block .= "- This post addresses the reader directly (second person). Every Rewrite MUST keep \"you\"/\"your\". Never shift to third person.\n"; } else { $block .= "- Keep the post's third-person point of view. Do not introduce \"I\" or \"you\".\n"; } $block .= "- Preserve the author's tense and tone ({$tone}, {$formality}). Rewrite content, never voice.\n"; if ($avg_len) { $block .= "- Match the author's rhythm (average sentence ≈ {$avg_len} words).\n"; } return $block; } /** v2.6.8: inline HTML allowed in FAQ answers (JS twin: AEOBobMatcher.FAQ_ANSWER_TAGS). */ public static function faq_answer_allowed_html() { return array( 'em' => array(), 'i' => array(), 'strong' => array(), 'b' => array(), 'a' => array('href' => true, 'title' => true, 'target' => true, 'rel' => true), 'br' => array(), 'span' => array(), ); } /** v2.6.8: FAQ answer → safe inline HTML (wp_kses allowlist; target=_blank gets rel=noopener). */ public function faq_answer_kses($html) { // wp_kses removes disallowed TAGS but keeps their text — drop dangerous elements whole. $html = preg_replace('#<(script|style|iframe|object|embed|noscript|template|textarea|svg|math)\b[^>]*>.*?#is', '', (string) $html); $html = preg_replace('#<(script|style|iframe|object|embed|noscript|template|textarea|svg|math)\b[^>]*>#i', '', $html); $html = wp_kses($html, self::faq_answer_allowed_html(), array('http', 'https', 'mailto')); return self::noopener_blank_links($html); } /** v2.6.8: add rel="noopener" to every target="_blank" link that lacks it. */ public static function noopener_blank_links($html) { return preg_replace_callback('#]*>#i', function ($m) { $tag = $m[0]; if (!preg_match('#\starget\s*=\s*(["\'])_blank\1#i', $tag)) { return $tag; } if (preg_match('#\srel\s*=\s*(["\'])(.*?)\1#i', $tag, $r)) { if (preg_match('#\bnoopener\b#i', $r[2])) { return $tag; } return str_replace($r[0], ' rel=' . $r[1] . trim($r[2] . ' noopener') . $r[1], $tag); } return substr($tag, 0, -1) . ' rel="noopener">'; }, (string) $html); } // ------------------------------------------------------------------ // Lite-specific (not copied from Pro). // ------------------------------------------------------------------ /** * v1.5.79: point of view of THIS post, counted the way Pro 2.5.9 does * (AEO_Bob_Content_Optimizer::analyze_writing_style): at least 8 first- or * second-person markers, otherwise third person. Call it on content with * the FAQ block stripped, so FAQ answers don't vote. * * @return string 'first', 'second' or 'third' */ public function detect_point_of_view($content) { $text = wp_strip_all_tags((string) $content); $first_person = preg_match_all('/\b(I|me|my|mine|myself|we|us|our|ours|ourselves)\b/i', $text); $second_person = preg_match_all('/\b(you|your|yours|yourself|yourselves)\b/i', $text); if ($first_person >= 8 && $first_person > $second_person) { return 'first'; } if ($second_person >= 8 && $second_person >= $first_person) { return 'second'; } return 'third'; } // ------------------------------------------------------------------ // v1.5.83: rewrites across inline formatting (live: billiardaimtrainer.com post 681). // ------------------------------------------------------------------ /** Inline tags a sentence may run through; every other tag (p, li, img, br, ...) ends a sentence. */ const INLINE_TAGS = array('em', 'strong', 'b', 'i', 'a', 'span', 'u', 'small', 'mark', 'sup', 'sub', 'abbr', 'cite', 'code'); /** * The text a reader sees, as an array of canonical characters, each mapped to the bytes of * $html it came from. Inline tags take no room ("Life," reads "Life,"), block tags, * images, line breaks, comments and shortcodes become "\n" (a sentence never crosses them, and * they are never inside a replaced span). Entities are decoded; whitespace (incl.  ) is * collapsed; curly quotes fold to straight ones; a dash run with its spaces becomes "-"; "…" is "...". * * @return array {chars: string[], start: int[], end: int[]} - byte range [start, end) per char */ public function visible_text_map($html) { $html = (string) $html; $chars = array(); $start = array(); $end = array(); $emit = function ($c, $s, $e) use (&$chars, &$start, &$end) { $chars[] = $c; $start[] = $s; $end[] = $e; }; $pop_space = function () use (&$chars, &$start, &$end) { if ($chars && end($chars) === ' ') { array_pop($chars); array_pop($start); array_pop($end); } }; $after_dash = false; $len = strlen($html); $re = '/|<(\/?)([a-zA-Z][a-zA-Z0-9]*)\b(?:[^>"\']|"[^"]*"|\'[^\']*\')*>|\[\/?[a-zA-Z_][\w-]*(?:\s[^\]]*)?\]|&(?:#[0-9]+|#x[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);|./su'; if (!preg_match_all($re, $html, $m, PREG_OFFSET_CAPTURE | PREG_SET_ORDER)) { return array('chars' => array(), 'start' => array(), 'end' => array()); } foreach ($m as $t) { $tok = $t[0][0]; $at = $t[0][1]; $to = $at + strlen($tok); if ($tok[0] === '<' && strlen($tok) > 1) { $tag = isset($t[2][0]) ? strtolower($t[2][0]) : ''; if ($tag !== '' && in_array($tag, self::INLINE_TAGS, true)) { continue; // zero width } if ($tag === '' && substr($tok, 0, 4) !== ' ... ). */ private function with_block_comments($html, $start, $end) { if (preg_match('/\s*$/', substr($html, max(0, $start - 300), $start - max(0, $start - 300)), $bm)) { $start -= strlen($bm[0]); } if (preg_match('/^\s*/', substr($html, $end, 300), $em)) { $end += strlen($em[0]); } return array($start, $end); } /** * v1.5.92: the question/answer pairs in the post's FAQ section(s), as plain text, for FAQPage * schema. Question headings (with or without "Q:") and the text up to the next heading, or * "Q: ..." / "A: ..." text. Pairs without an answer are left out; a question is listed once. * * @return array[] each array(question, answer) */ public function faq_pairs($html) { $html = (string) $html; $pairs = array(); $seen = array(); $add = function ($q, $a) use (&$pairs, &$seen) { $q = trim(preg_replace('/^Q\s*:\s*/i', '', $q)); $a = trim(preg_replace('/^A\s*:\s*/i', '', $a)); $key = strtolower($q); if ($q !== '' && $a !== '' && !isset($seen[$key])) { $seen[$key] = true; $pairs[] = array($q, $a); } }; foreach ($this->faq_sections($html) as $s) { $seg = substr($html, $s['heading_end'], $s['body_end'] - $s['heading_end']); $seg = preg_replace('//s', '', $seg); $min = 'heading' === $s['kind'] ? $s['level'] + 1 : 3; $qs = array_values(array_filter($this->headings_in($seg), function ($h) use ($min) { return $h['level'] >= $min && $h['text'] !== ''; })); if ($qs) { foreach ($qs as $i => $h) { $end = isset($qs[$i + 1]) ? $qs[$i + 1]['start'] : strlen($seg); $answer = preg_replace('#<(?:br|/p|/div|/li)\b[^>]*>#i', ' ', substr($seg, $h['end'], $end - $h['end'])); $add($h['text'], $this->heading_visible_text(strip_shortcodes($answer))); } continue; } $text = preg_replace('#<(?:br|/p|/div|/li|/h[1-6])\b[^>]*>#i', "\n", $seg); $text = html_entity_decode(strip_tags($text), ENT_QUOTES | ENT_HTML5, 'UTF-8'); if (preg_match_all('/^[ \t]*Q\s*:\s*(.+?)\s*\n\s*A\s*:\s*(.+?)(?=\n[ \t]*Q\s*:|\z)/ims', $text, $m, PREG_SET_ORDER)) { foreach ($m as $qa) { $add(preg_replace('/\s+/u', ' ', $qa[1]), preg_replace('/\s+/u', ' ', $qa[2])); } } } return $pairs; } /** Every h1-h6 heading: {start, end (block comments included), level, text}. */ private function headings_in($html) { $out = array(); if (preg_match_all('/]*>(.*?)<\/h\1\s*>/is', (string) $html, $m, PREG_OFFSET_CAPTURE)) { foreach ($m[0] as $i => $h) { list($start, $end) = $this->with_block_comments($html, $h[1], $h[1] + strlen($h[0])); $out[] = array('start' => $start, 'end' => $end, 'level' => (int) $m[1][$i][0], 'text' => $this->heading_visible_text($m[2][$i][0])); } } return $out; } /** * Every FAQ section in $html. The marker is an h2-h4 heading, or a block (p / div) - or, in * classic-editor storage, a line - whose only content is bold text; its visible text is FAQ, * FAQs, Frequently Asked Questions or Questions and Answers. The section runs to the next * heading of the same or a higher level (bold marker: the next h1/h2) or the next FAQ marker. * * @return array[] each {start, heading_end, body_end, insert_at, kind (heading|bold), level, * style (heading|qa_br|qa_blocks), item_tag (p|div|''), bold_q, q_level, lite} * lite = an H2 that is exactly "Frequently Asked Questions" (what Lite inserts) */ public function faq_sections($html) { $html = (string) $html; $headings = $this->headings_in($html); $markers = array(); foreach ($headings as $h) { if ($h['level'] >= 2 && $h['level'] <= 4 && preg_match(self::FAQ_MARKER_RE, $h['text'])) { $markers[] = array('start' => $h['start'], 'end' => $h['end'], 'kind' => 'heading', 'level' => $h['level'], 'lite' => $h['level'] === 2 && strcasecmp($h['text'], 'Frequently Asked Questions') === 0); } } $pad = '(?:\s| |)*'; if (preg_match_all('/<(p|div)\b[^>]*>' . $pad . '<(strong|b)\b[^>]*>((?:(?!<\/?(?:p|div)\b).)*?)<\/\2>' . $pad . '<\/\1>/is', $html, $m, PREG_OFFSET_CAPTURE)) { foreach ($m[0] as $i => $b) { if (preg_match(self::FAQ_MARKER_RE, $this->heading_visible_text($m[3][$i][0]))) { list($start, $end) = $this->with_block_comments($html, $b[1], $b[1] + strlen($b[0])); $markers[] = array('start' => $start, 'end' => $end, 'kind' => 'bold', 'level' => 0, 'lite' => false); } } } if (preg_match_all('/(?:^|\n)[ \t]*(<(strong|b)\b[^>]*>([^<]*)<\/\2>)[ \t]*(?=\r?\n|$)/i', $html, $m, PREG_OFFSET_CAPTURE)) { foreach ($m[1] as $i => $b) { if (preg_match(self::FAQ_MARKER_RE, $this->heading_visible_text($m[3][$i][0]))) { $markers[] = array('start' => $b[1], 'end' => $b[1] + strlen($b[0]), 'kind' => 'bold', 'level' => 0, 'lite' => false); } } } usort($markers, function ($a, $b) { return $a['start'] - $b['start']; }); // drop markers inside another marker (a bold line inside a matched

) $kept = array(); foreach ($markers as $mk) { if ($kept && $mk['start'] < $kept[count($kept) - 1]['end']) { continue; } $kept[] = $mk; } $out = array(); foreach ($kept as $i => $mk) { $body_end = strlen($html); foreach ($headings as $h) { $stops = $mk['kind'] === 'heading' ? $h['level'] <= $mk['level'] : $h['level'] <= 2; if ($h['start'] >= $mk['end'] && $stops) { $body_end = $h['start']; break; } } if (isset($kept[$i + 1]) && $kept[$i + 1]['start'] < $body_end) { $body_end = $kept[$i + 1]['start']; } $section = array('start' => $mk['start'], 'heading_end' => $mk['end'], 'body_end' => $body_end, 'kind' => $mk['kind'], 'level' => $mk['level'], 'lite' => $mk['lite']); $out[] = array_merge($section, $this->faq_section_style($html, $mk['end'], $body_end)); } return $out; } /** * How a section writes its items, and where a new one goes: "Q:"/"A:" in one element (qa_br, * "Q: ...
A: ..."), "Q:"/"A:" in separate blocks (qa_blocks), or question * headings (heading, h3 unless the section uses h4). */ private function faq_section_style($html, $from, $to) { $seg = substr($html, $from, $to - $from); if (preg_match_all('/(?:<(?:strong|b)\b[^>]*>\s*)?\bQ\s*:\s*/', $seg, $qm, PREG_OFFSET_CAPTURE) && preg_match_all('/\bA\s*:\s/', $seg, $am, PREG_OFFSET_CAPTURE)) { $last_q = end($qm[0]); $last_a = end($am[0]); $q_at = $last_q[1]; $a_at = $last_a[1]; // v1.5.91: "Q:" inside a question heading (

Q: ...?

then "A: ..." - live: foodyogi.org // page 1812) is the heading style with the prefixes kept, not plain Q/A paragraphs. $before_q = substr($seg, 0, $q_at); if (preg_match_all('/]*>/i', $before_q, $ho, PREG_OFFSET_CAPTURE)) { $open = end($ho[0]); $open_level = (int) end($ho[1])[0]; $close = strripos($before_q, ' 'heading', 'item_tag' => '', 'bold_q' => (bool) preg_match('/<(?:strong|b)\b/i', $inside . $last_q[0]), 'q_level' => $open_level, 'insert_at' => $to, 'q_prefix' => true, 'a_prefix' => (bool) preg_match('/\bA\s*:\s/', substr($seg, $q_at))); } } $between = substr($seg, $q_at, max(0, $a_at - $q_at)); $style = (preg_match('//i', $between) && !preg_match('/<\/(?:p|div)>/i', $between)) ? 'qa_br' : 'qa_blocks'; $tag = ''; if (preg_match_all('/<(p|div)\b[^>]*>/i', substr($seg, 0, $q_at), $tm)) { $tag = strtolower(end($tm[1])); // a bare classic-editor line after the last tag is not inside it $last_open = strripos(substr($seg, 0, $q_at), '<' . $tag); $last_close = strripos(substr($seg, 0, $q_at), ''); if ($last_close !== false && $last_close > $last_open) { $tag = ''; } } if ($tag === '') { // classic-editor storage (no

): Q and A on consecutive lines are one paragraph $style = preg_match('/\n[ \t]*\r?\n/', $between) ? 'qa_blocks' : 'qa_br'; } $bold_q = (bool) preg_match('/^<(?:strong|b)\b/i', $last_q[0]); // insert after the element that holds the last answer (or after its line) if ($tag !== '' && preg_match('/<\/' . $tag . '\s*>/i', $seg, $cm, PREG_OFFSET_CAPTURE, $a_at)) { $insert = $from + $cm[0][1] + strlen($cm[0][0]); list(, $insert) = $this->with_block_comments($html, $from + $cm[0][1], $insert); } else { $insert = preg_match('/\r?\n\s*\r?\n|\r?\n(?=\s*<)/', $seg, $nm, PREG_OFFSET_CAPTURE, $a_at) ? $from + $nm[0][1] : $to; } return array('style' => $style, 'item_tag' => $tag, 'bold_q' => $bold_q, 'q_level' => 3, 'insert_at' => min($insert, $to)); } $q_level = preg_match('/]*>(?:(?!<\/h[34]>).)*\?\s*(?:<\/(?:strong|b|em|i|span)>\s*)*<\/h[34]>/is', $seg, $hm) ? (int) $hm[1] : 3; return array('style' => 'heading', 'item_tag' => '', 'bold_q' => true, 'q_level' => $q_level, 'insert_at' => $to); } /** * New FAQ items in a section's style. $items: array of array($question_html, $answer_html) - * already escaped / sanitized by the caller. Block-editor posts get block markup. */ public function format_faq_items(array $section, array $items, $blocks) { $out = array(); $wrap_p = function ($inner) use ($blocks) { $p = '

' . $inner . '

'; return $blocks ? "\n" . $p . "\n" : $p; }; foreach ($items as $item) { list($q, $a) = $item; if ($section['style'] === 'heading') { $l = (int) $section['q_level']; // v1.5.91: keep the section's "Q:"/"A:" prefixes and its bold (or not) questions $qt = (!empty($section['q_prefix']) ? 'Q: ' : '') . $q; $qt = !empty($section['bold_q']) ? "$qt" : $qt; $h = $blocks ? "\n$qt\n" : "$qt"; $out[] = $h . "\n" . $wrap_p((!empty($section['a_prefix']) ? 'A: ' : '') . $a); continue; } $qq = $section['bold_q'] ? 'Q: ' . $q . '' : 'Q: ' . $q; $tag = $section['item_tag']; if ($section['style'] === 'qa_br') { $out[] = $tag === '' ? $qq . "\nA: " . $a : ($tag === 'p' ? $wrap_p($qq . "
\nA: " . $a) : "
$qq
\nA: $a
"); } else { $out[] = $tag === '' ? $qq . "\n\nA: " . $a : ($tag === 'p' ? $wrap_p($qq) . "\n" . $wrap_p('A: ' . $a) : "
$qq
\n
A: $a
"); } } return implode("\n\n", $out); } /** * Put new FAQ items into the post's FAQ section - the author's own section when there is one, * else the first - in that section's style. Returns null when the post has no FAQ section. */ public function insert_faq_items($html, array $items, $blocks) { $sections = $this->faq_sections($html); if (!$sections) { return null; } $authors = array_values(array_filter($sections, function ($s) { return !$s['lite']; })); $target = $authors ? $authors[0] : $sections[0]; $new = $this->format_faq_items($target, $items, $blocks); $at = $target['insert_at']; if ($target['style'] === 'heading') { $after = substr($html, $at); return rtrim(substr($html, 0, $at)) . "\n\n" . $new . (trim($after) !== '' ? "\n\n" . ltrim($after) : "\n"); } // tag-less (classic-editor) items are paragraphs separated by a blank line return substr($html, 0, $at) . ($target['item_tag'] === '' ? "\n\n" : "\n") . $new . substr($html, $at); } /** The question/answer pairs of a Lite-made section (question headings + what follows each). */ private function faq_items_of($html, array $section) { $seg = substr($html, $section['heading_end'], $section['body_end'] - $section['heading_end']); $items = array(); $qs = array(); foreach ($this->headings_in($seg) as $h) { if ($h['level'] >= 3) { $qs[] = $h; } } foreach ($qs as $i => $h) { $end = isset($qs[$i + 1]) ? $qs[$i + 1]['start'] : strlen($seg); $answer = substr($seg, $h['end'], $end - $h['end']); $answer = preg_replace('//s', '', $answer); $answer = trim(preg_replace('#]*>#i', ' ', $answer)); $answer = trim(preg_replace('/\s+/u', ' ', $answer)); if ($h['text'] !== '') { $items[] = array(esc_html($h['text']), $this->faq_answer_kses($answer)); } } return $items; } /** * Repair: FAQ sections Lite added go into the post's one FAQ section. With an author FAQ * section, Lite's sections are folded into it in the author's style (a question it already * has is not added twice); otherwise Lite's later sections move into its first one unchanged. * Nothing else moves. * * @return array {content, merged} - merged = number of sections folded in (0 = unchanged) */ public function merge_faq_sections($html) { $html = (string) $html; $sections = $this->faq_sections($html); $lite = array_values(array_filter($sections, function ($s) { return $s['lite']; })); $authors = array_values(array_filter($sections, function ($s) { return !$s['lite']; })); if (!$lite || (!$authors && count($lite) < 2)) { return array('content' => $html, 'merged' => 0); } $blocks = $this->has_block_markup($html); $fold = $authors ? $lite : array_slice($lite, 1); $moved_html = array(); $moved_items = array(); for ($i = count($fold) - 1; $i >= 0; $i--) { $s = $fold[$i]; if ($authors) { $moved_items = array_merge($this->faq_items_of($html, $s), $moved_items); } else { array_unshift($moved_html, trim(substr($html, $s['heading_end'], $s['body_end'] - $s['heading_end']))); } $html = rtrim(substr($html, 0, $s['start'])) . "\n\n" . ltrim(substr($html, $s['body_end'])); } if ($authors) { $existing = $this->extract_existing_faq_question_sets_all($html); $keep = array(); foreach ($moved_items as $item) { $q = html_entity_decode($item[0], ENT_QUOTES | ENT_HTML5, 'UTF-8'); if (!$this->faq_question_is_duplicate($q, $existing)) { $keep[] = $item; $existing[] = $this->faq_question_word_set($q); } } if ($keep) { $html = $this->insert_faq_items($html, $keep, $blocks); } return array('content' => rtrim($html) . "\n", 'merged' => count($fold)); } $first = array_values(array_filter($this->faq_sections($html), function ($s) { return $s['lite']; })); $at = $first[0]['body_end']; $after = substr($html, $at); $html = rtrim(substr($html, 0, $at)) . "\n\n" . implode("\n\n", array_filter($moved_html, 'strlen')) . (trim($after) !== '' ? "\n\n" . ltrim($after) : "\n"); return array('content' => $html, 'merged' => count($fold)); } /** * Existing FAQ questions as word sets: Pro's (h3/h4 headings, bold "Q: ...?" lines) plus every * "Q: ..." line in any markup - so "Q: What makes it unique?" and "What makes it unique" match * (the Q: prefix, case and punctuation are ignored by faq_question_word_set()). */ public function extract_existing_faq_question_sets_all($html) { $sets = $this->extract_existing_faq_question_sets($html); $text = preg_replace('#<(?:br|/p|/div|/li|/h[1-6])\b[^>]*>#i', "\n", (string) $html); $text = html_entity_decode(strip_tags($text), ENT_QUOTES | ENT_HTML5, 'UTF-8'); if (preg_match_all('/^[ \t]*Q\s*:\s*(.+)$/mi', $text, $m)) { foreach ($m[1] as $q) { $set = $this->faq_question_word_set($q); if ($set) { $sets[] = $set; } } } return $sets; } /** v1.5.83: does this post use block-editor markup? */ public function has_block_markup($html) { return (bool) preg_match('/