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]*?(?:\1\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('~^([a-z0-9]+)\s*>$~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('~?a\b(?:(?:"[^"]*"|\x27[^\x27]*\x27)|[^\x27">])*>~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]*?(?:\1\s*>|$)|<(?:(?:"[^"]*"|\x27[^\x27]*\x27)|[^\x27">])*>~i', substr($html, $end), $matches, PREG_OFFSET_CAPTURE);
foreach ($matches[0] as $m) {
if (preg_match('~^(?:p|li)\s*>$~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);
}
}
}
}