diff --git a/.github/changelog/add-content-mentions b/.github/changelog/add-content-mentions new file mode 100644 index 0000000..2cda5d4 --- /dev/null +++ b/.github/changelog/add-content-mentions @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Mention a Bluesky account with @handle.tld in your post: the mention now links to their profile on your site, and they are notified on Bluesky even on longer posts. diff --git a/includes/class-atmosphere.php b/includes/class-atmosphere.php index 7fb4d5e..543d26e 100644 --- a/includes/class-atmosphere.php +++ b/includes/class-atmosphere.php @@ -118,6 +118,13 @@ public function init(): void { \add_action( 'init', array( Options::class, 'init' ), 5 ); \add_action( 'init', array( Settings_Fields::class, 'init' ), 5 ); + /* + * Display-side @handle.tld mention auto-linking. Self-registers on + * init so the_content (priority 100) is wired for both front-end + * rendering and the site.standard.document content parsers. + */ + \add_action( 'init', array( Mention::class, 'init' ), 5 ); + /* * Seed the long-form composition strategy from the user's * setting. Priority 1 so any downstream filter at the default diff --git a/includes/class-mention.php b/includes/class-mention.php new file mode 100644 index 0000000..eecf68f --- /dev/null +++ b/includes/class-mention.php @@ -0,0 +1,376 @@ +` — that would corrupt the + * element rather than render a link. + * + * @var string[] + */ + private const PROTECTED_TAGS = array( 'a', 'code', 'pre', 'textarea', 'style', 'script', 'noscript', 'svg', 'iframe', 'title' ); + + /** + * Whether linkification is currently suppressed. + * + * The transformer renders post content through `the_content` to compose + * the Bluesky post text. Linkifying there would turn a plain `@handle` + * into an ``, which the post-text builder records as a `#link` facet + * (no notification) instead of a `#mention` facet (notifies). The builders + * wrap their `the_content` calls in {@see self::without_links()} so this + * guard short-circuits the filter for that path only. + * + * @var bool + */ + private static bool $suppressed = false; + + /** + * Register hooks. + * + * @return void + */ + public static function init(): void { + /* + * Priority 100: after the ActivityPub plugin's mention filter (99). + * A `@user@domain.tld` webfinger handle it has already wrapped in an + * anchor is then skipped here (protected `` tag) rather than + * double-linked. The negative lookbehind in self::linkify() makes the + * coexistence robust even when ActivityPub is not installed. + */ + \add_filter( 'the_content', array( self::class, 'the_content' ), 100 ); + } + + /** + * Linkify bare `@handle.tld` mentions in rendered HTML. + * + * @param string $content Rendered HTML. + * @return string + */ + public static function the_content( string $content ): string { + if ( self::$suppressed || '' === $content ) { + return $content; + } + + // Bound work on pathological input, mirroring ActivityPub's guard. + if ( \strlen( $content ) > MB_IN_BYTES ) { + return $content; + } + + return self::walk( + $content, + // Linkify a text chunk only when no protected tag is open. + static fn( string $text, bool $in_protected, string $prev_char ): string + => $in_protected ? $text : self::linkify( $text, $prev_char ) + ); + } + + /** + * Classify the `@handle.tld` mentions in rendered HTML by whether the + * front end would linkify them. + * + * Returns `array( 'linkable' => …, 'protected' => … )`: + * + * - `linkable`: a map of lowercased handle => first-seen handle for every + * mention the display linkifier ({@see self::the_content()}) would turn + * into a profile link, in first-appearance order. The publish path resolves + * and carries exactly this set, so a Bluesky `#mention` (and its + * notification) is only ever minted for a handle the site itself links. + * - `protected`: the set of lowercased handles that appear *only* inside a + * protected region (a ``/`
` sample or an existing ``).
+	 *   The transformer subtracts `linkable` from this to build the deny-set it
+	 *   passes to {@see Facet::extract()}, so a handle buried in a code sample
+	 *   never mints a `#mention` facet the front end leaves as plain text — even
+	 *   when it leaks into the excerpt.
+	 *
+	 * Shares the {@see self::walk()} tokenizer (and therefore the exact
+	 * protected-tag rules and cross-tag boundary handling) with the display
+	 * linkifier, so publish and display can never disagree about what is a
+	 * mention. Returns empty sets for empty or pathologically large content,
+	 * mirroring {@see self::the_content()}, which linkifies neither.
+	 *
+	 * @since unreleased
+	 *
+	 * @param string $content Rendered HTML.
+	 * @return array{linkable:array,protected:array}
+	 */
+	public static function classify_handles( string $content ): array {
+		$result = array(
+			'linkable'  => array(),
+			'protected' => array(),
+		);
+
+		// Same empty / pathological-input guards as the linkifier: when it
+		// would linkify nothing, the publish path must mint nothing.
+		if ( '' === $content || \strlen( $content ) > MB_IN_BYTES ) {
+			return $result;
+		}
+
+		self::walk(
+			$content,
+			static function ( string $text, bool $in_protected, string $prev_char ) use ( &$result ): string {
+				if ( $in_protected ) {
+					/*
+					 * Collect protected-region handles greedily (no boundary
+					 * prefix): over-collecting only ever *blocks* a mint, which
+					 * is the safe direction. `linkable` is subtracted from this,
+					 * so a handle that also appears in linkable text stays
+					 * mintable.
+					 */
+					if ( \preg_match_all( Facet::MENTION_PATTERN, $text, $matches ) ) {
+						foreach ( $matches[1] as $handle ) {
+							$result['protected'][ \strtolower( $handle ) ] = true;
+						}
+					}
+					return $text;
+				}
+
+				// Mirror self::linkify()'s cross-tag boundary so this scan and
+				// the display linkifier classify a handle the same way.
+				$prefix = self::boundary_prefix( $prev_char );
+				if ( \preg_match_all( Facet::MENTION_PATTERN, $prefix . $text, $matches ) ) {
+					foreach ( $matches[1] as $handle ) {
+						$key = \strtolower( $handle );
+						if ( ! isset( $result['linkable'][ $key ] ) ) {
+							$result['linkable'][ $key ] = $handle;
+						}
+					}
+				}
+				return $text;
+			}
+		);
+
+		return $result;
+	}
+
+	/**
+	 * Walk rendered HTML chunk by chunk, tracking the open-tag stack.
+	 *
+	 * Tags and comments pass through untouched; each text chunk is handed to
+	 * `$on_text( $text, $protected, $prev_char )`, where `$protected` is true
+	 * when a {@see self::PROTECTED_TAGS} element is currently open and
+	 * `$prev_char` is the last plain-text character emitted before this chunk
+	 * (see below). The callback's return value is emitted in its place.
+	 *
+	 * `$prev_char` carries the boundary across inline markup: text on either
+	 * side of a non-protected tag (`bob@example.com`) is glued, so the
+	 * mention boundary check sees the `@handle` as the tail of the preceding
+	 * word (an email) rather than a standalone handle — matching how the
+	 * publish path's flattened plain text reads it. Protected regions are elided
+	 * from the linkified stream, so their text does not advance the boundary.
+	 *
+	 * @param string   $content Rendered HTML.
+	 * @param callable $on_text fn(string $text, bool $in_protected, string $prev_char): string.
+	 * @return string
+	 */
+	private static function walk( string $content, callable $on_text ): string {
+		$tag_stack = array();
+		$out       = '';
+		$prev_char = '';
+
+		foreach ( \wp_html_split( $content ) as $chunk ) {
+			// HTML comment: copy through untouched.
+			if ( \preg_match( '#^$#', $chunk ) ) {
+				$out .= $chunk;
+				continue;
+			}
+
+			// Opening / closing tag: maintain the stack, never transform a tag.
+			if ( \preg_match( '#^<(/)?([a-z][a-z0-9-]*)\b[^>]*>$#i', $chunk, $m ) ) {
+				$tag = \strtolower( $m[2] );
+				if ( '/' === $m[1] ) {
+					/*
+					 * Unwind to the *most recently* opened tag of this name,
+					 * not the first. For well-formed nesting the match is the
+					 * stack top, so only it is popped; with same-name nesting
+					 * (e.g. ``) popping the first
+					 * match would drop the still-open outer tag and linkify
+					 * text that is in fact still protected.
+					 */
+					$keys = \array_keys( $tag_stack, $tag, true );
+					if ( ! empty( $keys ) ) {
+						$tag_stack = \array_slice( $tag_stack, 0, \end( $keys ) );
+					}
+				} elseif ( ! self::is_self_closing( $chunk ) ) {
+					/*
+					 * Push opening tags only. A self-closed tag (``,
+					 * `