tag markup with Postlinkly-controlled attributes.
*
* @package Postlinkly
* @since 1.0.0
*/
declare(strict_types=1);
namespace Postlinkly\Link;
/**
* Builder for `` tag markup with Postlinkly-controlled attributes.
*
* Produces deterministic, escaped, single-quoted-or-double-quoted markup
* suitable for direct splicing into post content. Centralising tag
* construction here means insertion call sites don't reinvent attribute
* escaping and keeps the markup shape consistent across the plugin.
*
* Standard attribute order:
* 1. href (always present, always first after the tag name)
* 2. target (when opening in a new tab)
* 3. rel (when nofollow / sponsored / noopener)
* 4. title (when set)
* 5. data-postlinkly (always — marks links the plugin owns)
* 6. extra data-* (custom)
*
* Anchor text is escaped with `esc_html` to avoid HTML injection from
* user-supplied keyword phrases.
*
* @since 1.0.0
*/
final class LinkAttributeBuilder {
/**
* Marker attribute set on every link the plugin creates or modifies.
*
* Lets the plugin tell its own links apart from editor-added links
* during scans and during link-removal operations.
*/
public const PLUGIN_MARKER_ATTRIBUTE = 'data-postlinkly';
/**
* Builds an `` tag.
*
* @since 1.0.0
*
* @param string $url Target URL. Will be passed through esc_url().
* @param string $anchor Anchor text. Will be passed through esc_html().
* @param array $options {
* Optional. Attribute options.
*
* @type bool $new_tab Open in a new tab. Default false.
* @type bool $nofollow Add rel="nofollow". Default false.
* @type bool $sponsored Add rel="sponsored". Default false.
* @type string $title Optional title attribute.
* @type string $marker_value Override the data-postlinkly value.
* Default '1'.
* @type array $extra_data Extra data-* attributes (without "data-" prefix).
* }
*
* @return string Fully escaped `` markup.
*/
public function build( string $url, string $anchor, array $options = array() ): string {
$href = function_exists( 'esc_url' ) ? esc_url( $url ) : self::escape_attribute( $url );
$text = function_exists( 'esc_html' ) ? esc_html( $anchor ) : self::escape_html( $anchor );
$pieces = array( 'href="' . $href . '"' );
$new_tab = ! empty( $options['new_tab'] );
$nofollow = ! empty( $options['nofollow'] );
$sponsored = ! empty( $options['sponsored'] );
if ( $new_tab ) {
$pieces[] = 'target="_blank"';
}
$rel_tokens = array();
if ( $new_tab ) {
$rel_tokens[] = 'noopener';
$rel_tokens[] = 'noreferrer';
}
if ( $nofollow ) {
$rel_tokens[] = 'nofollow';
}
if ( $sponsored ) {
$rel_tokens[] = 'sponsored';
}
if ( array() !== $rel_tokens ) {
$pieces[] = 'rel="' . implode( ' ', array_unique( $rel_tokens ) ) . '"';
}
if ( ! empty( $options['title'] ) ) {
$title = function_exists( 'esc_attr' )
? esc_attr( (string) $options['title'] )
: self::escape_attribute( (string) $options['title'] );
$pieces[] = 'title="' . $title . '"';
}
$marker_value = isset( $options['marker_value'] ) && '' !== $options['marker_value']
? (string) $options['marker_value']
: '1';
$marker_value = function_exists( 'esc_attr' )
? esc_attr( $marker_value )
: self::escape_attribute( $marker_value );
$pieces[] = self::PLUGIN_MARKER_ATTRIBUTE . '="' . $marker_value . '"';
if ( ! empty( $options['extra_data'] ) && is_array( $options['extra_data'] ) ) {
foreach ( $options['extra_data'] as $key => $value ) {
if ( ! is_string( $key ) || '' === $key ) {
continue;
}
$clean_key = preg_replace( '/[^a-z0-9-]/i', '', $key );
if ( ! is_string( $clean_key ) || '' === $clean_key ) {
continue;
}
$attr_value = function_exists( 'esc_attr' )
? esc_attr( (string) $value )
: self::escape_attribute( (string) $value );
$pieces[] = 'data-' . $clean_key . '="' . $attr_value . '"';
}
}
return '' . $text . '';
}
/**
* Plain-PHP attribute escape for environments where WordPress isn't loaded.
*
* Used only by unit tests; production calls go through esc_attr/esc_url.
*
* @since 1.0.0
*/
private static function escape_attribute( string $value ): string {
return htmlspecialchars( $value, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8' );
}
/**
* Plain-PHP HTML escape for environments where WordPress isn't loaded.
*
* @since 1.0.0
*/
private static function escape_html( string $value ): string {
return htmlspecialchars( $value, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8' );
}
}