attributes that hold URLs.
*
* @var array
*/
const TAG_ATTRS = array(
'a' => array( 'href' ),
'img' => array( 'src' ),
'iframe' => array( 'src' ),
'video' => array( 'src', 'poster' ),
'audio' => array( 'src' ),
'source' => array( 'src' ),
'embed' => array( 'src' ),
);
/**
* Base URL for resolving relative links.
*
* @var string
*/
private $base = '';
/**
* Sets the base URL used to resolve relative URLs.
*
* @param string $base Absolute URL.
* @return $this
*/
public function with_base( $base ) {
$this->base = $base ? $base : home_url( '/' );
return $this;
}
/**
* Extracts from any post content (HTML with optional block comments).
*
* @param string $content Content.
* @param string $field Field label stored with each instance.
* @return array[]
*/
public function from_content( $content, $field ) {
$content = (string) $content;
if ( '' === trim( $content ) ) {
return array();
}
$found = $this->from_html( $content, $field );
if ( function_exists( 'has_blocks' ) && has_blocks( $content ) ) {
$found = array_merge( $found, $this->from_blocks( parse_blocks( $content ), $field ) );
}
if ( false !== strpos( $content, '[' ) ) {
$found = array_merge( $found, $this->from_shortcodes( $content, $field ) );
}
// Bare URLs on their own line become oEmbeds.
if ( preg_match_all( '#^\s*(https?://[^\s<>"\']+)\s*$#im', $content, $m ) ) {
foreach ( $m[1] as $url ) {
$found[] = $this->item( $url, $field, 'embed', '' );
}
}
return $this->finish( $found, $field );
}
/**
* Extracts from an HTML fragment.
*
* @param string $html HTML.
* @param string $field Field label.
* @return array[]
*/
public function from_html( $html, $field ) {
$found = array();
$html = (string) $html;
if ( false === strpos( $html, '<' ) ) {
return $found;
}
if ( ! preg_match_all( '#<(a|img|iframe|video|audio|source|embed)\b[^>]*>#i', $html, $matches, PREG_OFFSET_CAPTURE ) ) {
return $found;
}
foreach ( $matches[0] as $index => $match ) {
$tag_html = $match[0];
$offset = $match[1];
$tag = strtolower( $matches[1][ $index ][0] );
$processor = new WP_HTML_Tag_Processor( $tag_html );
if ( ! $processor->next_tag() ) {
continue;
}
foreach ( self::TAG_ATTRS[ $tag ] as $attr ) {
$value = $processor->get_attribute( $attr );
if ( ! is_string( $value ) || '' === trim( $value ) ) {
continue;
}
$anchor = '';
if ( 'a' === $tag ) {
$end = stripos( $html, '', $offset );
if ( false !== $end ) {
$inner = substr( $html, $offset + strlen( $tag_html ), $end - $offset - strlen( $tag_html ) );
$anchor = trim( preg_replace( '/\s+/', ' ', wp_strip_all_tags( $inner ) ) );
if ( '' === $anchor && preg_match( '#
get_attribute( 'alt' );
$anchor = is_string( $alt ) ? $alt : '';
}
$element = 'a' === $tag ? 'a' : ( 'img' === $tag ? 'img' : $tag );
$found[] = $this->item( $value, $field, $element, $anchor );
}
}
return $found;
}
/**
* Extracts URL-like attributes from parsed blocks (embeds, navigation links, buttons).
*
* @param array $blocks Parsed blocks.
* @param string $field Field label.
* @return array[]
*/
public function from_blocks( array $blocks, $field ) {
$found = array();
foreach ( $blocks as $block ) {
if ( ! empty( $block['attrs'] ) && is_array( $block['attrs'] ) ) {
$this->walk_attrs( $block['attrs'], $field, $found );
}
if ( ! empty( $block['innerBlocks'] ) ) {
$found = array_merge( $found, $this->from_blocks( $block['innerBlocks'], $field ) );
}
}
return $found;
}
/**
* Walks block attributes for URL values.
*
* @param array $attrs Attributes.
* @param string $field Field label.
* @param array $found Collected items (by reference).
* @return void
*/
private function walk_attrs( array $attrs, $field, array &$found ) {
foreach ( $attrs as $key => $value ) {
if ( is_array( $value ) ) {
$this->walk_attrs( $value, $field, $found );
continue;
}
if ( ! is_string( $value ) ) {
continue;
}
$key_name = strtolower( (string) $key );
if ( preg_match( '/^(url|href|src|link|linkurl|mediaurl|poster)$/', $key_name ) && preg_match( '#^(https?:)?//#i', $value ) ) {
$found[] = $this->item( $value, $field, 'block', '' );
}
}
}
/**
* Reads URLs from shortcode attributes without running any shortcode.
*
* Page builders such as Divi, WPBakery, Avada and Beaver Builder's shortcode mode keep
* button, image and video URLs in attributes like url="", button_url="", src="" or
* link="url:...|title:...". The content is only parsed as text: no shortcode callback
* runs, so there are no side effects, no extra queries and no outgoing requests.
*
* @param string $content Content.
* @param string $field Field label.
* @return array[]
*/
public function from_shortcodes( $content, $field ) {
$found = array();
$content = (string) $content;
if ( ! preg_match_all( '/(? $value ) {
if ( ! is_string( $key ) || ! is_string( $value ) ) {
continue;
}
$item = $this->shortcode_attr_item( strtolower( $key ), trim( $value ), $field );
if ( null !== $item ) {
$found[] = $item;
}
}
}
return $found;
}
/**
* One URL item from a shortcode attribute, or null.
*
* @param string $key Attribute name (lower case).
* @param string $value Attribute value.
* @param string $field Field label.
* @return array|null
*/
private function shortcode_attr_item( $key, $value, $field ) {
// WPBakery link fields: url:https%3A%2F%2Fexample.com|title:Text|target:_blank.
if ( 0 === strpos( $value, 'url:' ) && preg_match( '/^url:([^|]+)/', $value, $m ) ) {
$url = rawurldecode( $m[1] );
return preg_match( '#^https?://#i', $url ) ? $this->item( $url, $field, 'shortcode', '' ) : null;
}
/**
* Filters the shortcode attribute names that hold a URL.
*
* @param string $pattern Regular expression matched against the lower-case attribute name.
*/
$pattern = (string) apply_filters( 'linktender_shortcode_url_attributes', '/(^|_)(url|href|src|link|image|img|poster|mp4|webm|ogg|mp3|logo|background_image|bg_image)$/' );
if ( ! preg_match( $pattern, $key ) || ! preg_match( '#^(https?:)?//[^\s"\'<>]+$#i', $value ) ) {
return null;
}
$is_image = preg_match( '/(image|img|logo|poster)$/', $key ) || preg_match( '#\.(png|jpe?g|gif|webp|avif|svg)(\?.*)?$#i', $value );
$element = $is_image ? 'img' : 'shortcode';
return $this->item( $value, $field, $element, '' );
}
/**
* Extracts from Elementor's JSON page data.
*
* @param string|array $data JSON string or decoded array.
* @param string $field Field label.
* @return array[]
*/
public function from_elementor( $data, $field ) {
if ( is_string( $data ) ) {
$data = json_decode( $data, true );
}
if ( ! is_array( $data ) ) {
return array();
}
$found = array();
$this->walk_elementor( $data, $field, $found, '' );
return $this->finish( $found, $field );
}
/**
* Recursive Elementor walker.
*
* @param array $node Node.
* @param string $field Field label.
* @param array $found Collected items (by reference).
* @param string $parent_key Parent key.
* @return void
*/
private function walk_elementor( array $node, $field, array &$found, $parent_key ) {
foreach ( $node as $key => $value ) {
if ( is_array( $value ) ) {
$this->walk_elementor( $value, $field, $found, (string) $key );
continue;
}
if ( ! is_string( $value ) || '' === $value ) {
continue;
}
if ( 'url' === $key && preg_match( '#^(https?:)?//#i', $value ) ) {
$element = ( false !== strpos( $parent_key, 'image' ) ) ? 'img' : 'a';
$found[] = $this->item( $value, $field, $element, '' );
} elseif ( false !== strpos( $value, '<' ) ) {
$found = array_merge( $found, $this->from_html( $value, $field ) );
}
}
}
/**
* Extracts from a generic stored value (custom fields, widget settings).
*
* @param mixed $value Value (string or nested array).
* @param string $field Field label.
* @return array[]
*/
public function from_value( $value, $field ) {
$found = array();
$this->walk_value( $value, $field, $found, 0 );
return $this->finish( $found, $field );
}
/**
* Recursive walker for generic values.
*
* @param mixed $value Value.
* @param string $field Field label.
* @param array $found Collected (by reference).
* @param int $depth Recursion depth.
* @return void
*/
private function walk_value( $value, $field, array &$found, $depth ) {
if ( $depth > 8 ) {
return;
}
if ( is_array( $value ) ) {
foreach ( $value as $child ) {
$this->walk_value( $child, $field, $found, $depth + 1 );
}
return;
}
if ( ! is_string( $value ) || '' === $value ) {
return;
}
$trimmed = trim( $value );
if ( preg_match( '#^https?://[^\s<>"]+$#i', $trimmed ) ) {
$found[] = $this->item( $trimmed, $field, 'field', '' );
return;
}
if ( false !== strpos( $value, '<' ) ) {
$found = array_merge( $found, $this->from_html( $value, $field ) );
if ( has_blocks( $value ) ) {
$found = array_merge( $found, $this->from_blocks( parse_blocks( $value ), $field ) );
}
}
}
/**
* Builds one raw item.
*
* @param string $raw URL as written.
* @param string $field Field label.
* @param string $element Element type.
* @param string $anchor Anchor text.
* @return array
*/
private function item( $raw, $field, $element, $anchor ) {
return array(
'raw_url' => $raw,
'field' => $field,
'element' => $element,
'anchor' => (string) $anchor,
);
}
/**
* Resolves, filters and de-duplicates raw items.
*
* @param array[] $found Raw items.
* @param string $field Field label.
* @return array[]
*/
public function finish( array $found, $field ) {
$settings = LinkTender_Settings::all();
$out = array();
$seen = array();
$home = wp_parse_url( home_url(), PHP_URL_HOST );
foreach ( $found as $item ) {
$url = $this->resolve( $item['raw_url'] );
if ( null === $url ) {
continue;
}
if ( 'img' === $item['element'] && empty( $settings['check_images'] ) ) {
continue;
}
if ( in_array( $item['element'], array( 'iframe', 'video', 'audio', 'source', 'embed' ), true ) && empty( $settings['check_media'] ) ) {
continue;
}
$host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) );
if ( empty( $settings['check_internal'] ) && $host === $home ) {
continue;
}
if ( LinkTender_Links::is_admin_or_login( $url ) || LinkTender_Settings::is_url_excluded( $url ) ) {
continue;
}
// Block and embed hits repeat what the HTML pass already found.
$key = $url . '|' . $item['raw_url'];
if ( isset( $seen[ $key ] ) ) {
continue;
}
$seen[ $key ] = true;
$item['url'] = $url;
$out[] = $item;
}
/**
* Filters URLs extracted from one field.
*
* @param array[] $out Items with url, raw_url, field, element, anchor.
* @param string $field Field label.
*/
return apply_filters( 'linktender_extracted_urls', $out, $field );
}
/**
* Makes a URL absolute; returns null for URLs that cannot be checked.
*
* @param string $raw URL as written.
* @return string|null
*/
public function resolve( $raw ) {
$url = LinkTender_Links::normalize( $raw );
if ( '' === $url ) {
return null;
}
if ( preg_match( '#^([a-z][a-z0-9+.-]*):#i', $url, $m ) ) {
$scheme = strtolower( $m[1] );
if ( 'http' !== $scheme && 'https' !== $scheme ) {
// Skip non-web schemes such as mail, phone, script and data links.
return null;
}
} else {
if ( false !== strpos( $url, '{' ) || false !== strpos( $url, '[' ) ) {
return null; // Template placeholders and shortcodes.
}
$url = WP_Http::make_absolute_url( $url, $this->base ? $this->base : home_url( '/' ) );
}
if ( ! wp_http_validate_url( $url ) && ! preg_match( '#^https?://[^/\s]+#i', $url ) ) {
return null;
}
$host = wp_parse_url( $url, PHP_URL_HOST );
if ( empty( $host ) || strlen( $url ) > 2000 ) {
return null;
}
return $url;
}
}