post_content, '[et_pb_') !== false) return 'divi'; if ($post && strpos($post->post_content, '[vc_') !== false) return 'wpbakery'; return $post && has_blocks($post->post_content) ? 'gutenberg' : 'classic'; } public static function snapshot($id) { $post = get_post($id); return ['content' => $post ? $post->post_content : '', 'elementor' => get_post_meta($id, '_elementor_data', true)]; } public static function hash($id) { return hash('sha256', wp_json_encode(self::snapshot($id))); } public static function fragments($id) { $post = get_post($id); if (!$post) return []; $editor = self::editor($id); $out = []; if ($editor === 'elementor') { $data = get_post_meta($id, '_elementor_data', true); $data = is_string($data) ? json_decode($data, true) : $data; if (is_array($data)) self::elementor_fragments($data, [], $out); } elseif ($editor === 'gutenberg') { self::block_fragments(parse_blocks($post->post_content), [], $out); } elseif ($editor === 'divi') { self::divi_fragments($post->post_content, $out); } elseif ($editor === 'classic') { $out[] = ['location' => __( 'Post content', 'linkbraid' ), 'path' => [], 'html' => $post->post_content]; } // Unknown builder sources are intentionally read-only until a dedicated adapter exists. return $out; } /** * Divi stores the builder as shortcodes in post_content. The text module * ([et_pb_text]) has a known HTML contract; its inner content is addressed * byte-precisely (offset + length), so the write is a plain substr_replace * and every other byte of the layout stays untouched. Modules that contain * nested builder shortcodes stay read-only. */ private static function divi_fragments($content, &$out) { if (!function_exists('get_shortcode_regex')) return; if (!preg_match_all('/' . get_shortcode_regex(['et_pb_text']) . '/s', $content, $matches, PREG_OFFSET_CAPTURE)) return; foreach ($matches[5] as $i => $inner) { if (($matches[1][$i][0] ?? '') === '[') continue; // escaped [[et_pb_text]] if (!is_array($inner) || $inner[1] < 0) continue; // self-closing module $html = $inner[0]; if (trim($html) === '' || strpos($html, '[et_pb_') !== false) continue; /* translators: %d: dynamic value inserted at runtime. */ $out[] = ['path' => ['divi', $inner[1], strlen($html)], 'location' => sprintf( __( 'Divi · text module %d', 'linkbraid' ), $i + 1 ), 'html' => $html]; } } private static function block_fragments($blocks, $parent, &$out) { foreach ($blocks as $i => $block) { $path = array_merge($parent, [$i]); if (!empty($block['attrs']['metadata']['bindings'])) continue; if (in_array($block['blockName'], ['core/paragraph', 'core/list-item', 'core/list'], true) && empty($block['innerBlocks'])) { /* translators: %1$s, %2$s: dynamic values in display order. */ $out[] = ['path' => $path, 'location' => sprintf( __( 'Block %1$s · %2$s', 'linkbraid' ), implode('.', $path), $block['blockName'] ), 'html' => $block['innerHTML']]; } if (in_array($block['blockName'], ['core/group', 'core/columns', 'core/column', 'core/list'], true)) { self::block_fragments($block['innerBlocks'], array_merge($path, ['innerBlocks']), $out); } } } private static function elementor_fragments($elements, $parent, &$out) { foreach ($elements as $i => $el) { $path = array_merge($parent, [$i]); // Rich-text widgets have a known HTML contract. Titles, HTML/code widgets and menus do not. if (($el['widgetType'] ?? '') === 'text-editor' && isset($el['settings']['editor']) && is_string($el['settings']['editor'])) { /* translators: %s: dynamic value inserted at runtime. */ $out[] = ['path' => array_merge($path, ['settings', 'editor']), 'location' => sprintf( __( 'Elementor · %s', 'linkbraid' ), $el['id'] ?? implode('.', $path) ), 'html' => $el['settings']['editor']]; } if (!empty($el['elements']) && is_array($el['elements'])) self::elementor_fragments($el['elements'], array_merge($path, ['elements']), $out); } } /** Tokens preserve exact bytes, including attributes, JSON comments and entities. */ public static function tokens($html, $include_links = false) { preg_match_all('~|$)|<(script|style|textarea|pre|code|template|noscript)\b[^>]*>[\s\S]*?(?:|$)|<(?:(?:"[^"]*"|\x27[^\x27]*\x27)|[^\x27">])*>|[^<]+|<~i', $html, $matches, PREG_OFFSET_CAPTURE); $out = []; $stack = []; foreach ($matches[0] as $match) { list($raw, $offset) = $match; if ($raw[0] === '<') { if (preg_match('~^$~i', $raw, $m)) { $tag = strtolower($m[1]); for ($i = count($stack) - 1; $i >= 0; $i--) if ($stack[$i]['tag'] === $tag) { $stack = array_slice($stack, 0, $i); break; } } elseif (preg_match('~^<([a-z0-9]+)\b~i', $raw, $m) && !preg_match('~^<(script|style|textarea|pre|code|template|noscript)\b~i', $raw)) { $tag = strtolower($m[1]); $blocked = ($tag === 'a' && !$include_links) || in_array($tag, ['h1','h2','h3','h4','h5','h6','nav','header','footer','button','select','svg','math'], true) || preg_match('~\s(?:hidden|inert)(?:\s|=|>)|aria-hidden\s*=\s*["\x27]true|display\s*:\s*none|visibility\s*:\s*hidden~i', $raw); if (!in_array($tag, ['br','hr','img','input','meta','link','source','wbr','area','base','embed','param','track','col'], true) && substr($raw, -2) !== '/>') $stack[] = ['tag' => $tag, 'blocked' => $blocked]; } continue; } $blocked = false; foreach ($stack as $entry) if ($entry['blocked']) $blocked = true; if (!$blocked && trim($raw) !== '') { // Shortcode attributes are code, even though they are not enclosed in HTML tags. $parts = preg_split('/(\[(?:\/?)[a-zA-Z_][^\]]*\])/', $raw, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_OFFSET_CAPTURE); foreach ($parts as $part) { if (trim($part[0]) === '' || preg_match('/^\[\/?[a-zA-Z_]/', $part[0])) continue; $out[] = ['raw' => $part[0], 'offset' => $offset + $part[1], 'text' => html_entity_decode($part[0], ENT_QUOTES | ENT_HTML5, 'UTF-8')]; } } } return $out; } public static function plain($html, $include_links = false) { // A newly inserted anchor must not add whitespace before punctuation in // the context used to locate the remaining suggestions from this analysis. $text = ''; $previous = null; foreach (self::tokens($html, $include_links) as $token) { $separator = ' '; if ($include_links && $previous !== null) { $end = $previous['offset'] + strlen($previous['raw']); $gap = substr($html, $end, $token['offset'] - $end); $between = preg_replace('~])*>~i', '', $gap); if ($between !== $gap && trim($between) === '') $separator = $between; } $text .= ($previous === null ? '' : $separator) . $token['text']; $previous = $token; } return trim(preg_replace('/[\s\x{00a0}]+/u', ' ', $text)); } public static function context_matches($html, $context) { if (mb_strpos(self::plain($html, true), $context) !== false) return true; // Keep proposals generated before the context whitespace correction usable. $legacy = trim(preg_replace('/[\s\x{00a0}]+/u', ' ', implode(' ', array_column(self::tokens($html, true), 'text')))); return mb_strpos($legacy, $context) !== false; } public static function append_sentence($html, $sentence) { $tokens = self::tokens($html); if (!$tokens) return null; $last = end($tokens); $end = $last['offset'] + strlen($last['raw']); // Find the containing paragraph/list-item boundary, skipping comments, raw code and quoted attributes. preg_match_all('~|$)|<(script|style|textarea|pre|code|template|noscript)\b[^>]*>[\s\S]*?(?:|$)|<(?:(?:"[^"]*"|\x27[^\x27]*\x27)|[^\x27">])*>~i', substr($html, $end), $matches, PREG_OFFSET_CAPTURE); foreach ($matches[0] as $m) { if (preg_match('~^$~i', $m[0])) { $offset = $end + $m[1]; return substr($html, 0, $offset) . ' ' . $sentence . substr($html, $offset); } if (preg_match('~^<(?:p|li|nav|header|footer)\b~i', $m[0])) return null; } return $end === strlen($html) ? $html . ' ' . $sentence : null; } public static function text($id) { return implode("\n\n", array_map(function($f) { return self::plain($f['html']); }, self::fragments($id))); } public static function insert($html, $anchor, $url) { if (trim($anchor) === '') return null; $pattern = '~(? 0]; $decoded = 0; preg_match_all('/&(?:#[0-9]+|#x[0-9a-f]+|[a-z][a-z0-9]+);|./isu', $raw, $units, PREG_OFFSET_CAPTURE); foreach ($units[0] as $unit) { $decoded += strlen(html_entity_decode($unit[0], ENT_QUOTES | ENT_HTML5, 'UTF-8')); $map[$decoded] = $unit[1] + strlen($unit[0]); } $start = $m[0][1]; $end = $start + strlen($m[0][0]); if (!isset($map[$start], $map[$end])) continue; $a = $token['offset'] + $map[$start]; $b = $token['offset'] + $map[$end]; return [ 'html' => substr($html, 0, $a) . '' . substr($html, $a, $b - $a) . '' . substr($html, $b), 'anchor' => trim(preg_replace('/[\s\x{00a0}]+/u', ' ', $m[0][0])), ]; } return null; } /** * Removes the first pointing at $url, keeping its inner content. * String surgery on the original HTML (like insert()) — the DOM is never * serialized back into a post. Returns the modified HTML or null. */ public static function unlink($html, $url) { $needle = untrailingslashit(strtolower(trim(html_entity_decode((string) $url, ENT_QUOTES | ENT_HTML5, 'UTF-8')))); if ($needle === '') return null; if (!preg_match_all('~])*)>([\s\S]*?)~i', $html, $matches, PREG_OFFSET_CAPTURE)) return null; foreach ($matches[0] as $i => $full) { if (!preg_match('~\bhref\s*=\s*(?:"([^"]*)"|\x27([^\x27]*)\x27|([^\s">]+))~i', $matches[1][$i][0], $m)) continue; $href = $m[1] !== '' ? $m[1] : (isset($m[2]) && $m[2] !== '' ? $m[2] : ($m[3] ?? '')); $href = untrailingslashit(strtolower(trim(html_entity_decode($href, ENT_QUOTES | ENT_HTML5, 'UTF-8')))); if ($href !== $needle) continue; $offset = $full[1]; return substr($html, 0, $offset) . $matches[2][$i][0] . substr($html, $offset + strlen($full[0])); } return null; } /** * Replaces the FIRST href equal to $old with $new (case- and trailing-slash- * insensitive comparison, entity-decoded). Only the attribute value changes; * anchor text, other attributes and markup stay byte-identical. Returns the * modified HTML or null when no link matches. */ public static function replace_url($html, $old, $new) { $needle = untrailingslashit(strtolower(trim(html_entity_decode((string) $old, ENT_QUOTES | ENT_HTML5, 'UTF-8')))); $new = trim((string) $new); if ($needle === '' || $new === '') return null; if (!preg_match_all('~])*)>~i', $html, $matches, PREG_OFFSET_CAPTURE)) return null; foreach ($matches[1] as $attrs) { if (!preg_match('~\bhref\s*=\s*(?:"([^"]*)"|\x27([^\x27]*)\x27|([^\s">]+))~i', $attrs[0], $m, PREG_OFFSET_CAPTURE)) continue; $value = null; foreach ([1, 2, 3] as $group) { if (isset($m[$group]) && $m[$group][1] !== -1) { $value = $m[$group]; break; } } if ($value === null) continue; $href = untrailingslashit(strtolower(trim(html_entity_decode($value[0], ENT_QUOTES | ENT_HTML5, 'UTF-8')))); if ($href !== $needle) continue; $start = $attrs[1] + $value[1]; return substr($html, 0, $start) . esc_url($new) . substr($html, $start + strlen($value[0])); } return null; } public static function resolve_url($href) { $href = trim(html_entity_decode($href, ENT_QUOTES | ENT_HTML5, 'UTF-8')); if ($href === '' || $href[0] === '#') return false; $home = wp_parse_url(home_url('/')); if (strpos($href, '//') === 0) $href = $home['scheme'] . ':' . $href; elseif ($href[0] === '/') $href = $home['scheme'] . '://' . $home['host'] . (isset($home['port']) ? ':' . $home['port'] : '') . $href; $parts = wp_parse_url($href); if (!$parts || !in_array(strtolower($parts['scheme'] ?? ''), ['http','https'], true) || strtolower($parts['host'] ?? '') !== strtolower($home['host']) || isset($parts['user']) || isset($parts['pass']) || ($parts['port'] ?? null) !== ($home['port'] ?? null)) return false; return esc_url_raw($href); } public static function links($html) { $out = []; foreach (self::links_all($html) as $link) if ($link['internal']) $out[] = $link; return $out; } /** All visible links of a fragment: internal (normalized URL) and external http(s). */ public static function links_all($html) { // DOM is used only for reading; never serialize a DOM tree back into a post. if (!class_exists('DOMDocument')) return []; $old = libxml_use_internal_errors(true); $dom = new DOMDocument(); $dom->loadHTML('
' . $html . '
', LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING); libxml_clear_errors(); libxml_use_internal_errors($old); $home_host = strtolower(wp_parse_url(home_url('/'))['host'] ?? ''); $out = []; foreach ($dom->getElementsByTagName('a') as $a) { $skip = false; for ($p = $a; $p instanceof DOMElement; $p = $p->parentNode) { if (in_array(strtolower($p->tagName), ['script','style','template','noscript','code','pre','textarea','nav','header','footer','svg'], true) || $p->hasAttribute('hidden') || $p->hasAttribute('inert') || $p->getAttribute('aria-hidden') === 'true' || preg_match('/display\s*:\s*none|visibility\s*:\s*hidden/i', $p->getAttribute('style'))) $skip = true; } if ($skip || trim($a->textContent) === '') continue; $anchor = trim($a->textContent); $internal = self::resolve_url($a->getAttribute('href')); if ($internal) { $out[] = ['url' => $internal, 'anchor' => $anchor, 'internal' => true, 'host' => $home_host]; continue; } $href = trim(html_entity_decode($a->getAttribute('href'), ENT_QUOTES | ENT_HTML5, 'UTF-8')); $parts = $href === '' ? false : wp_parse_url($href); if (!$parts || !in_array(strtolower($parts['scheme'] ?? ''), ['http', 'https'], true) || empty($parts['host']) || isset($parts['user']) || isset($parts['pass'])) continue; if (strtolower($parts['host']) === $home_host) continue; // internal, but rejected by resolve_url (a different port, for example) $out[] = ['url' => esc_url_raw($href), 'anchor' => $anchor, 'internal' => false, 'host' => strtolower($parts['host'])]; } return $out; } public static function &at(&$data, $path) { $ref =& $data; foreach ($path as $key) $ref =& $ref[$key]; return $ref; } /** * ACF text carriers of a post (text, textarea, wysiwyg — also inside * repeaters, groups and flexible content), read-only. They feed the link * scan and the semantic index; suggestions are still written only into the * main editor content. */ public static function acf_fragments($post_id) { if (!function_exists('get_field_objects') || !get_option('lbraid_scan_acf', 1)) return []; $objects = get_field_objects($post_id); if (!is_array($objects) || !$objects) return []; $out = []; foreach ($objects as $field) self::acf_collect((array) $field, $field['value'] ?? null, $out); return apply_filters('lbraid_acf_fragments', $out, $post_id); } /** Plain text of the ACF carriers, capped — for the embedding context. */ public static function acf_text($post_id, $max = 4000) { $parts = []; foreach (self::acf_fragments($post_id) as $fragment) { $parts[] = trim(wp_strip_all_tags((string) $fragment['html'])); } return mb_substr(trim(preg_replace('/\s+/u', ' ', implode(' ', array_filter($parts)))), 0, $max); } public static function acf_collect(array $field, $value, array &$out) { $type = (string) ($field['type'] ?? ''); if (in_array($type, ['text', 'textarea', 'wysiwyg'], true)) { if (is_string($value) && trim($value) !== '') { $out[] = ['html' => $value, 'location' => 'ACF: ' . (string) ($field['label'] ?? $field['name'] ?? 'field')]; } return; } if (!in_array($type, ['repeater', 'group', 'flexible_content'], true) || !is_array($value)) return; $subs = []; foreach ((array) ($field['sub_fields'] ?? []) as $sub) $subs[$sub['name']] = $sub; if ($type === 'flexible_content') { foreach ((array) ($field['layouts'] ?? []) as $layout) { foreach ((array) ($layout['sub_fields'] ?? []) as $sub) $subs[$sub['name']] = $sub; } } $rows = $type === 'group' ? [$value] : $value; foreach ($rows as $row) { if (!is_array($row)) continue; foreach ($row as $name => $sub_value) { if (isset($subs[$name])) self::acf_collect((array) $subs[$name], $sub_value, $out); } } } }