” 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 ) ); } }