” and, unclosed, run to the end, as in browsers; possessive, so that matching stays linear
*/
private const TAG_INSIDE = '(?:[^>=]|=\s*+(?>"[^"]*+(?:"|\z)|\'[^\']*+(?:\'|\z))?+)*+';
/**
* Genitive endings a link includes (“Agatha Christies,” “Agatha Christie’s”)
*/
private const SUFFIXES = '(?:s|’s|’s|\'s)?';
/**
* Returns HTML with the first mention of each target’s phrases linked
*
* Matches are case-sensitive and respect word boundaries; at the same position, longer phrases win. Each target is linked once, and each paragraph (or other block) gets one link at most, so that links spread out.
*
* @param string $html HTML.
* @param array $urls Phrases and their target URLs.
* @param int $count Maximum number of links.
* @param string $class_link Class of the links, if any.
* @param array $linked Phrases that got linked, with their target URLs, in order.
*/
public static function link( string $html, array $urls, int $count, string $class_link = '', array &$linked = [] ): string {
$linked = [];
if ( ! $urls || $count < 1 ) {
return $html;
}
uksort( $urls, fn( string $a, string $b ): int => mb_strlen( $b ) <=> mb_strlen( $a ) );
// Raw text elements as one token each; unclosed tags and elements run to the end, as in browsers
$elements_raw = implode( '|', array_map( fn( string $element ): string => '<' . $element . '\b' . self::TAG_INSIDE . '>.*?(?:<\/' . $element . '\s*>|\z)', self::ELEMENTS_RAW ) );
$tokens = preg_split( '/(|' . $elements_raw . '|<' . self::TAG_INSIDE . '(?:>|\z))/is', $html, -1, PREG_SPLIT_DELIM_CAPTURE );
if ( false === $tokens ) {
return $html;
}
$depth_skip = 0;
$block_linked = false;
foreach ( $tokens as $index => $token ) {
if ( str_starts_with( $token, '<' ) ) {
if ( ! preg_match( '/^<(\/?)([a-z][a-z0-9]*)/i', $token, $match ) ) {
continue;
}
$element = strtolower( $match[2] );
if ( in_array( $element, self::ELEMENTS_SKIP, true ) ) {
$closed = str_ends_with( $token, '/>' ) || preg_match( '/<\/' . $element . '\s*>$/i', $token );
$depth_skip = '/' === $match[1] ? max( 0, $depth_skip - 1 ) : $depth_skip + ( $closed ? 0 : 1 );
}
if ( in_array( $element, self::ELEMENTS_BLOCK, true ) ) {
$block_linked = false;
}
} elseif ( 0 === $depth_skip && ! $block_linked && '' !== trim( $token ) ) {
$tokens[ $index ] = self::link_text( $token, $urls, $class_link, $linked );
if ( $tokens[ $index ] !== $token ) {
$block_linked = true;
--$count;
}
if ( ! $urls || $count < 1 ) {
break;
}
}//end if
}//end foreach
return implode( '', $tokens );
}
/**
* Returns the phrases that HTML contains, as a quick check before linking
*
* Text in HTML is escaped, so phrases are, too; this may keep phrases that `link()` will not link (like those in attributes), but never drops one it would link.
*
* @template T
* @param string $html HTML.
* @param array $phrases Phrases, with any values.
* @return array
*/
public static function mentioned( string $html, array $phrases ): array {
return array_filter( $phrases, fn( $phrase ): bool => str_contains( $html, htmlspecialchars( (string) $phrase, ENT_NOQUOTES, 'UTF-8' ) ), ARRAY_FILTER_USE_KEY );
}
/**
* Links the first phrase mention in a text node, if any, and removes its target
*
* @param string $text Text node (HTML-escaped).
* @param array $urls Phrases and their target URLs, longest first.
* @param string $class_link Class of the link, if any.
* @param array $linked Phrases linked so far, with their target URLs.
*/
private static function link_text( string $text, array &$urls, string $class_link, array &$linked ): string {
$phrases = [];
foreach ( array_keys( $urls ) as $phrase ) {
$phrases[ htmlspecialchars( $phrase, ENT_NOQUOTES, 'UTF-8' ) ] = $phrase;
}
$pattern = '/(? preg_quote( $phrase, '/' ), array_keys( $phrases ) ) ) . ')' . self::SUFFIXES . '(?![\p{L}\p{N}])/u';
if ( ! preg_match( $pattern, $text, $match, PREG_OFFSET_CAPTURE ) ) {
return $text;
}
[ $mention, $offset ] = $match[0];
$phrase = $phrases[ $match[1][0] ];
$url = $urls[ $phrase ];
$linked[ $phrase ] = $url;
$urls = array_filter( $urls, fn( string $url_other ): bool => $url_other !== $url );
$attribute_class = '' === $class_link ? '' : ' class="' . htmlspecialchars( $class_link, ENT_QUOTES, 'UTF-8' ) . '"';
return substr( $text, 0, $offset ) . '' . $mention . '' . substr( $text, $offset + strlen( $mention ) );
}
}