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