From 9ade3fa834ce698247693ecd05dd604081b32fbc Mon Sep 17 00:00:00 2001 From: Jeremy Herve Date: Wed, 24 Jun 2026 19:44:53 +0200 Subject: [PATCH 01/21] Add Facet::resolve_handles() with a request-scoped resolution memo --- includes/transformer/class-facet.php | 91 ++++++++++++++++++- .../tests/transformer/class-test-facet.php | 35 +++++++ 2 files changed, 125 insertions(+), 1 deletion(-) diff --git a/includes/transformer/class-facet.php b/includes/transformer/class-facet.php index 244f01e..af74c1c 100644 --- a/includes/transformer/class-facet.php +++ b/includes/transformer/class-facet.php @@ -19,6 +19,33 @@ */ class Facet { + /** + * Regex matching an AT Protocol `@handle.tld` mention. + * + * Capture group 1 is the bare handle (no leading `@`). Requires at + * least two dot-separated labels, mirroring DNS-name handle syntax. + * Shared by {@see self::mentions()} and {@see self::resolve_handles()}. + * + * @var string + */ + private const MENTION_PATTERN = '/@([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+)/u'; + + /** + * Request-scoped memo of handle => DID resolutions. + * + * Broadening mention collection to the full post body resolves the same + * handle more than once per publish (the carry-over detection pass and + * the final {@see self::extract()} on the composed text). Memoizing the + * DNS/`did:web` result keeps that to one lookup per distinct handle per + * request, bounding duplicate DNS egress. Keyed by lowercased handle. + * + * The self-handle short-circuit is intentionally evaluated outside this + * cache, since it depends on the live connection option. + * + * @var array + */ + private static array $resolution_cache = array(); + /** * Extract all facet types from a piece of text. * @@ -271,7 +298,7 @@ private static function links( string $text ): array { */ private static function mentions( string $text ): array { $facets = array(); - $pattern = '/@([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+)/u'; + $pattern = self::MENTION_PATTERN; if ( ! \preg_match_all( $pattern, $text, $matches, PREG_OFFSET_CAPTURE ) ) { return $facets; @@ -311,6 +338,44 @@ private static function mentions( string $text ): array { return $facets; } + /** + * Find resolvable `@handle.tld` mentions in a piece of text. + * + * Returns a map of handle => DID for every distinct, resolvable mention, + * in first-appearance order. Handles that fail resolution (malformed, or + * not a valid DNS name) are omitted, so a handle present in the result is + * guaranteed to produce a `#mention` facet when it reaches a record's + * `text`. Shares the regex and resolver used to build mention facets. + * + * @param string $text Plain text. + * @return array Map of handle => DID. + */ + public static function resolve_handles( string $text ): array { + if ( ! \preg_match_all( self::MENTION_PATTERN, $text, $matches ) ) { + return array(); + } + + $handles = array(); + $seen = array(); + + foreach ( $matches[1] as $handle ) { + $key = \strtolower( $handle ); + if ( isset( $seen[ $key ] ) ) { + continue; + } + $seen[ $key ] = true; + + $did = self::resolve_mention( $handle ); + if ( '' === $did ) { + continue; + } + + $handles[ $handle ] = $did; + } + + return $handles; + } + /** * Find #hashtags and return tag facets. * @@ -380,6 +445,30 @@ private static function resolve_mention( string $handle ): string { return ''; } + $key = \strtolower( $handle ); + if ( \array_key_exists( $key, self::$resolution_cache ) ) { + return self::$resolution_cache[ $key ]; + } + + $did = self::resolve_handle_via_dns( $handle ); + + self::$resolution_cache[ $key ] = $did; + + return $did; + } + + /** + * Resolve a syntactically-valid handle to a DID over DNS. + * + * Looks up the `_atproto.` TXT record and falls back to + * `did:web:` when no `did=` record is found. Split out of + * {@see self::resolve_mention()} so the self-handle short-circuit and the + * syntactic gate stay outside the resolution memo. + * + * @param string $handle Valid AT Protocol handle. + * @return string DID string. + */ + private static function resolve_handle_via_dns( string $handle ): string { $records = @\dns_get_record( '_atproto.' . $handle, DNS_TXT ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged if ( \is_array( $records ) ) { diff --git a/tests/phpunit/tests/transformer/class-test-facet.php b/tests/phpunit/tests/transformer/class-test-facet.php index 8891f2a..260353d 100644 --- a/tests/phpunit/tests/transformer/class-test-facet.php +++ b/tests/phpunit/tests/transformer/class-test-facet.php @@ -70,6 +70,41 @@ public function test_single_label_mention_produces_no_facet() { $this->assertCount( 0, $mention_facets ); } + /** + * The resolve_handles() method returns resolvable body mentions as handle => DID. + */ + public function test_resolve_handles_returns_resolvable_mentions() { + $handles = Facet::resolve_handles( 'Hi @alice.bsky.social and @bob.example!' ); + + $this->assertArrayHasKey( 'alice.bsky.social', $handles ); + $this->assertArrayHasKey( 'bob.example', $handles ); + $this->assertNotSame( '', $handles['alice.bsky.social'] ); + } + + /** + * A single-label `@bareword` is not a handle and is not returned. + */ + public function test_resolve_handles_skips_single_label() { + $handles = Facet::resolve_handles( 'Hey @notadomain over there' ); + + $this->assertArrayNotHasKey( 'notadomain', $handles ); + } + + /** + * The same handle mentioned twice appears once. + */ + public function test_resolve_handles_deduplicates() { + $handles = Facet::resolve_handles( '@alice.bsky.social then @alice.bsky.social again' ); + + $count = 0; + foreach ( \array_keys( $handles ) as $key ) { + if ( 'alice.bsky.social' === \strtolower( $key ) ) { + ++$count; + } + } + $this->assertSame( 1, $count ); + } + /** * Test that trailing punctuation is stripped from URLs. */ From efcbd1c110d842b940c302c03c24821b027331b9 Mon Sep 17 00:00:00 2001 From: Jeremy Herve Date: Wed, 24 Jun 2026 19:49:26 +0200 Subject: [PATCH 02/21] Carry body @mentions into long-form Bluesky posts so they notify --- includes/transformer/class-post.php | 120 +++++++++++++++++- .../tests/transformer/class-test-post.php | 65 ++++++++++ 2 files changed, 182 insertions(+), 3 deletions(-) diff --git a/includes/transformer/class-post.php b/includes/transformer/class-post.php index 811c324..41a371b 100644 --- a/includes/transformer/class-post.php +++ b/includes/transformer/class-post.php @@ -664,7 +664,7 @@ private function build_text(): string { $text = \implode( "\n\n", $parts ); if ( \mb_strlen( $text ) <= self::BLUESKY_MAX_GRAPHEMES ) { - return $text; + return $this->carry_body_mentions( $text, $permalink ); } // Reserve space for permalink + separators. @@ -684,7 +684,121 @@ private function build_text(): string { $prose = truncate_text( $prose, $available ); - return $prose . "\n\n" . $permalink; + return $this->carry_body_mentions( $prose . "\n\n" . $permalink, $permalink ); + } + + /** + * Resolvable `@handle.tld` mentions found in the full post body. + * + * Returns a map of handle => DID, first-appearance order. Empty for + * redacted posts and in projection mode (the preview must not resolve + * mentions over DNS — see {@see self::$projecting}). + * + * @return array + */ + private function body_mentions(): array { + if ( $this->is_redacted() || $this->projecting ) { + return array(); + } + + return Facet::resolve_handles( $this->render_post_content_plain( $this->object ) ); + } + + /** + * Carry resolvable body @mentions into a long-form post text. + * + * No-op when the post has no resolvable body mentions, so a mention-free + * record composes byte-identically to the un-carried text. Otherwise the + * resolvable body handles not already present in the text are appended as + * a single space-separated line placed immediately before the trailing + * permalink, so {@see Facet::extract()} attaches a `#mention` facet and + * Bluesky notifies the mentioned accounts even when the mention lived deep + * in the post body. + * + * The permalink is preserved in full (it is the load-bearing link); as + * many handles as fit are kept; the prose shrinks last to stay within the + * 300-grapheme cap. Handles that still don't fit are dropped and logged. + * + * @param string $text Composed post text (may end with `\n\n$permalink`). + * @param string $permalink Post permalink, or '' when the text carries no + * trailing link. + * @return string + */ + private function carry_body_mentions( string $text, string $permalink ): string { + $handles = $this->body_mentions(); + if ( empty( $handles ) ) { + return $text; + } + + $sep = "\n\n"; + $max = self::BLUESKY_MAX_GRAPHEMES; + + // Peel a trailing permalink off the prose so the mention line lands + // before it. + $suffix = ''; + $prose = $text; + if ( '' !== $permalink && \str_ends_with( $text, $sep . $permalink ) ) { + $suffix = $sep . $permalink; + $prose = \substr( $text, 0, \strlen( $text ) - \strlen( $suffix ) ); + } elseif ( '' !== $permalink && $text === $permalink ) { + $suffix = $permalink; + $prose = ''; + } + + // Handles not already visible in the prose, in order. + $missing = array(); + foreach ( $handles as $handle => $did ) { + if ( false === \mb_stripos( $prose, '@' . $handle ) ) { + $missing[] = '@' . $handle; + } + } + if ( empty( $missing ) ) { + return $text; + } + + // Greedily fit handles into the room left after the permalink. A + // handle that cannot fit even against an empty prose is dropped. + $suffix_len = \mb_strlen( $suffix ); + $kept = ''; + $dropped = 0; + foreach ( $missing as $mention ) { + $candidate = '' === $kept ? $mention : $kept . ' ' . $mention; + // Worst case needs a separator before the line; reserve one. + if ( \mb_strlen( $candidate ) + \mb_strlen( $sep ) + $suffix_len > $max ) { + ++$dropped; + continue; + } + $kept = $candidate; + } + + if ( '' === $kept ) { + return $text; + } + + if ( $dropped > 0 ) { + debug_log( + \sprintf( + 'post %d: %d body mention(s) dropped from the Bluesky post — no room within the %d-character limit', + $this->object->ID, + $dropped, + $max + ) + ); + } + + // Shrink the prose to fit the chosen line + permalink. + $line_sep = '' !== $prose ? \mb_strlen( $sep ) : 0; + $prose_budget = $max - \mb_strlen( $kept ) - $line_sep - $suffix_len; + + if ( $prose_budget <= 0 ) { + $prose = ''; + } elseif ( \mb_strlen( $prose ) > $prose_budget ) { + $prose = truncate_text( $prose, $prose_budget ); + } + + $head = '' !== $prose ? $prose . $sep : ''; + + return $head . $kept . $suffix; } /** @@ -2166,7 +2280,7 @@ private function build_truncate_link_text(): string { $body = $this->truncate_to_budget( $plain, $budget - \mb_strlen( $separator ), false ); - return $body . $separator . $permalink; + return $this->carry_body_mentions( $body . $separator . $permalink, $permalink ); } /** diff --git a/tests/phpunit/tests/transformer/class-test-post.php b/tests/phpunit/tests/transformer/class-test-post.php index f7321e2..3ac9b70 100644 --- a/tests/phpunit/tests/transformer/class-test-post.php +++ b/tests/phpunit/tests/transformer/class-test-post.php @@ -3989,4 +3989,69 @@ public function test_project_does_not_upload_blobs() { $this->assertSame( 0, $http_calls, 'Projection must not make HTTP requests.' ); $this->assertSame( '', \get_post_meta( $attachment_id, '_atmosphere_blob_ref', true ) ); } + + /** + * A @mention deep in the body of a long-form (link-card) post is carried + * into the Bluesky post text before the permalink, producing a #mention + * facet so the account is notified. + */ + public function test_link_card_carries_body_mention_before_permalink() { + $post = self::factory()->post->create_and_get( + array( + 'post_title' => 'A normal long-form title', + 'post_excerpt' => 'A short teaser excerpt.', + 'post_content' => 'Intro paragraph with no handle.' . \str_repeat( ' filler', 60 ) . ' Shout-out to @alice.bsky.social near the end.', + ) + ); + + $record = ( new Post( $post ) )->transform(); + $permalink = \get_permalink( $post ); + + $this->assertStringContainsString( '@alice.bsky.social', $record['text'] ); + $this->assertStringEndsWith( "\n\n" . $permalink, $record['text'] ); + $this->assertStringContainsString( "@alice.bsky.social\n\n" . $permalink, $record['text'] ); + $this->assertLessThanOrEqual( 300, \mb_strlen( $record['text'] ) ); + + $mention_facets = \array_filter( + $record['facets'] ?? array(), + static fn( $facet ) => 'app.bsky.richtext.facet#mention' === ( $facet['features'][0]['$type'] ?? '' ) + ); + $this->assertCount( 1, $mention_facets ); + } + + /** + * A mention already visible in the composed text (title/excerpt) is not + * duplicated by the carry-over. + */ + public function test_link_card_does_not_duplicate_visible_mention() { + $post = self::factory()->post->create_and_get( + array( + 'post_title' => 'Thanks @alice.bsky.social for the help', + 'post_content' => 'Body that also names @alice.bsky.social again.', + ) + ); + + $record = ( new Post( $post ) )->transform(); + + $this->assertSame( 1, \substr_count( $record['text'], '@alice.bsky.social' ) ); + } + + /** + * A post with no body mentions composes byte-identically to before + * (carry-over is a no-op). + */ + public function test_link_card_without_mentions_is_unchanged() { + $post = self::factory()->post->create_and_get( + array( + 'post_title' => 'Plain title', + 'post_excerpt' => 'Plain excerpt.', + 'post_content' => 'Body with no handles at all.', + ) + ); + + $record = ( new Post( $post ) )->transform(); + $permalink = \get_permalink( $post ); + + $this->assertSame( "Plain title\n\nPlain excerpt.\n\n" . $permalink, $record['text'] ); + } } From fee75e40367cc7b39fe275380ee6fdfe01094aff Mon Sep 17 00:00:00 2001 From: Jeremy Herve Date: Wed, 24 Jun 2026 19:53:31 +0200 Subject: [PATCH 03/21] Carry body @mentions into the teaser-thread CTA so they notify --- includes/transformer/class-post.php | 55 +++++++++++++++++++ .../tests/transformer/class-test-post.php | 31 +++++++++++ 2 files changed, 86 insertions(+) diff --git a/includes/transformer/class-post.php b/includes/transformer/class-post.php index 41a371b..8007183 100644 --- a/includes/transformer/class-post.php +++ b/includes/transformer/class-post.php @@ -801,6 +801,60 @@ private function carry_body_mentions( string $text, string $permalink ): string return $head . $kept . $suffix; } + /** + * Prepend resolvable body @mentions absent from a teaser thread into its + * terminal CTA entry, before the permalink. + * + * A mention already shipping in any thread entry (hook or body chunk) + * already notifies, so only handles absent from every entry are carried. + * They are prepended to the CTA (the entry that holds the permalink), + * dropping any that don't fit the 300-grapheme cap; the CTA text is never + * trimmed. No-op when the post has no resolvable body mentions. + * + * @param string[] $texts Thread entry texts, in order (CTA last). + * @return string[] + */ + private function carry_mentions_into_teaser( array $texts ): array { + $handles = $this->body_mentions(); + if ( empty( $handles ) || \count( $texts ) < 1 ) { + return $texts; + } + + $shipped = \implode( "\n", $texts ); + + $missing = array(); + foreach ( $handles as $handle => $did ) { + if ( false === \mb_stripos( $shipped, '@' . $handle ) ) { + $missing[] = '@' . $handle; + } + } + if ( empty( $missing ) ) { + return $texts; + } + + $last = \count( $texts ) - 1; + $cta = $texts[ $last ]; + $sep = "\n\n"; + $room = self::BLUESKY_MAX_GRAPHEMES - \mb_strlen( $cta ) - \mb_strlen( $sep ); + + $kept = ''; + foreach ( $missing as $mention ) { + $candidate = '' === $kept ? $mention : $kept . ' ' . $mention; + if ( \mb_strlen( $candidate ) > $room ) { + break; + } + $kept = $candidate; + } + + if ( '' === $kept ) { + return $texts; + } + + $texts[ $last ] = $kept . $sep . $cta; + + return $texts; + } + /** * Build an `app.bsky.embed.images` record from the post's images. * @@ -2101,6 +2155,7 @@ public function build_long_form_records( int $stored_count = 0 ): array { } $texts = $this->build_teaser_thread( $default_texts ); + $texts = $this->carry_mentions_into_teaser( $texts ); $records = array(); $last = \count( $texts ) - 1; // Attach an `app.bsky.embed.external` link card to the diff --git a/tests/phpunit/tests/transformer/class-test-post.php b/tests/phpunit/tests/transformer/class-test-post.php index 3ac9b70..08115f7 100644 --- a/tests/phpunit/tests/transformer/class-test-post.php +++ b/tests/phpunit/tests/transformer/class-test-post.php @@ -4054,4 +4054,35 @@ public function test_link_card_without_mentions_is_unchanged() { $this->assertSame( "Plain title\n\nPlain excerpt.\n\n" . $permalink, $record['text'] ); } + + /** + * A teaser-thread carries a body mention (absent from hook and chunk) into + * the terminal CTA entry, before the permalink, producing a #mention facet. + */ + public function test_teaser_thread_carries_body_mention_into_cta() { + \add_filter( 'atmosphere_long_form_composition', static fn() => 'teaser-thread' ); + + $post = self::factory()->post->create_and_get( + array( + 'post_title' => 'A long teaser-thread post', + 'post_excerpt' => 'Curated excerpt that becomes the hook.', + 'post_content' => 'Opening body paragraph.' . \str_repeat( ' more body', 80 ) . ' Final shout-out to @alice.bsky.social here.', + ) + ); + + $records = ( new Post( $post ) )->build_long_form_records(); + + \remove_all_filters( 'atmosphere_long_form_composition' ); + + $cta = \end( $records ); + $this->assertStringContainsString( '@alice.bsky.social', $cta['text'] ); + $this->assertStringContainsString( 'Continue reading', $cta['text'] ); + $this->assertLessThanOrEqual( 300, \mb_strlen( $cta['text'] ) ); + + $mention_facets = \array_filter( + $cta['facets'] ?? array(), + static fn( $facet ) => 'app.bsky.richtext.facet#mention' === ( $facet['features'][0]['$type'] ?? '' ) + ); + $this->assertCount( 1, $mention_facets ); + } } From a1d64936d7eb495a088ea60eda4a2f115c460606 Mon Sep 17 00:00:00 2001 From: Jeremy Herve Date: Wed, 24 Jun 2026 19:57:12 +0200 Subject: [PATCH 04/21] Add display-side @handle.tld mention auto-linking --- includes/class-atmosphere.php | 7 + includes/class-mention.php | 162 +++++++++++++++++++++ tests/phpunit/tests/class-test-mention.php | 78 ++++++++++ 3 files changed, 247 insertions(+) create mode 100644 includes/class-mention.php create mode 100644 tests/phpunit/tests/class-test-mention.php diff --git a/includes/class-atmosphere.php b/includes/class-atmosphere.php index 3c22708..a5239e9 100644 --- a/includes/class-atmosphere.php +++ b/includes/class-atmosphere.php @@ -117,6 +117,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..be572e7 --- /dev/null +++ b/includes/class-mention.php @@ -0,0 +1,162 @@ +`, 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; + } + + $tag_stack = array(); + $out = ''; + + 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 linkify a tag. + if ( \preg_match( '#^<(/)?([a-z0-9]+)\b[^>]*>$#i', $chunk, $m ) ) { + $tag = \strtolower( $m[2] ); + if ( '/' === $m[1] ) { + $i = \array_search( $tag, $tag_stack, true ); + if ( false !== $i ) { + $tag_stack = \array_slice( $tag_stack, 0, $i ); + } + } else { + $tag_stack[] = $tag; + } + $out .= $chunk; + continue; + } + + // Text chunk: linkify only when no protected tag is open. + if ( \array_intersect( $tag_stack, self::PROTECTED_TAGS ) ) { + $out .= $chunk; + continue; + } + + $out .= self::linkify( $chunk ); + } + + return $out; + } + + /** + * Run a callback with mention linkification suppressed. + * + * @param callable $callback Callback to run. + * @return mixed Callback return value. + */ + public static function without_links( callable $callback ): mixed { + $previous = self::$suppressed; + self::$suppressed = true; + + try { + return $callback(); + } finally { + self::$suppressed = $previous; + } + } + + /** + * Replace `@handle.tld` with a link to the Bluesky profile. + * + * No DNS: the handle goes straight into the bsky.app profile URL, which + * resolves the handle itself. A negative lookbehind on the `@` skips the + * domain half of an ActivityPub `@user@domain.tld` handle (and ordinary + * email addresses) — a preceding word char, `@`, or `.` disqualifies the + * match. + * + * @param string $text Plain-text chunk (no tags). + * @return string + */ + private static function linkify( string $text ): string { + $pattern = '/(?@%s', + \esc_url( $url ), + \esc_html( $handle ) + ); + }, + $text + ); + + // preg_replace_callback returns null on PCRE failure; keep the text. + return \is_string( $replaced ) ? $replaced : $text; + } +} diff --git a/tests/phpunit/tests/class-test-mention.php b/tests/phpunit/tests/class-test-mention.php new file mode 100644 index 0000000..b2d594b --- /dev/null +++ b/tests/phpunit/tests/class-test-mention.php @@ -0,0 +1,78 @@ +Hello @alice.bsky.social!

' ); + + $this->assertStringContainsString( + '@alice.bsky.social', + $out + ); + } + + /** + * A @mention already inside an anchor is left alone (no double-link). + */ + public function test_skips_existing_anchor() { + $html = '

@alice.bsky.social

'; + + $this->assertSame( $html, Mention::the_content( $html ) ); + } + + /** + * A @mention inside is left alone. + */ + public function test_skips_code() { + $html = '

@alice.bsky.social

'; + + $this->assertSame( $html, Mention::the_content( $html ) ); + } + + /** + * The domain half of an ActivityPub @user@domain.tld handle is not linked. + */ + public function test_skips_activitypub_webfinger_form() { + $out = Mention::the_content( '

Hi @pfefferle@notiz.blog there

' ); + + $this->assertStringNotContainsString( 'Mail me at bob@example.com today

' ); + + $this->assertStringNotContainsString( 'Just @someone here

' ); + + $this->assertStringNotContainsString( ' Date: Wed, 24 Jun 2026 20:03:11 +0200 Subject: [PATCH 05/21] Guard Bluesky-text rendering so mentions stay #mention facets --- includes/transformer/class-base.php | 5 ++- includes/transformer/class-post.php | 5 ++- tests/phpunit/tests/class-test-mention.php | 14 +++++++++ .../tests/transformer/class-test-post.php | 31 +++++++++++++++++++ 4 files changed, 53 insertions(+), 2 deletions(-) diff --git a/includes/transformer/class-base.php b/includes/transformer/class-base.php index a4f42b8..a3c4cf4 100644 --- a/includes/transformer/class-base.php +++ b/includes/transformer/class-base.php @@ -12,6 +12,7 @@ \defined( 'ABSPATH' ) || exit; +use Atmosphere\Mention; use function Atmosphere\build_at_uri; use function Atmosphere\get_did; use function Atmosphere\sanitize_text; @@ -196,7 +197,9 @@ protected function render_post_content_plain( \WP_Post $post ): string { return $this->plain_content_cache[ $post->ID ]; } - $content = \apply_filters( 'the_content', $post->post_content ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core WordPress filter. + $content = Mention::without_links( + static fn() => \apply_filters( 'the_content', $post->post_content ) // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core WordPress filter. + ); $plain = sanitize_text( $content ); $this->plain_content_cache[ $post->ID ] = $plain; diff --git a/includes/transformer/class-post.php b/includes/transformer/class-post.php index 8007183..d818688 100644 --- a/includes/transformer/class-post.php +++ b/includes/transformer/class-post.php @@ -14,6 +14,7 @@ \defined( 'ABSPATH' ) || exit; use Atmosphere\API; +use Atmosphere\Mention; use function Atmosphere\debug_log; use function Atmosphere\sanitize_text; use function Atmosphere\truncate_text; @@ -1721,7 +1722,9 @@ private function build_short_form_text(): array { ); } - $html = \apply_filters( 'the_content', $this->object->post_content ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core WordPress filter. + $html = Mention::without_links( + fn() => \apply_filters( 'the_content', $this->object->post_content ) // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core WordPress filter. + ); /* * Fast path: no anchors, so the plain render is the whole story. diff --git a/tests/phpunit/tests/class-test-mention.php b/tests/phpunit/tests/class-test-mention.php index b2d594b..c501ec9 100644 --- a/tests/phpunit/tests/class-test-mention.php +++ b/tests/phpunit/tests/class-test-mention.php @@ -75,4 +75,18 @@ public function test_skips_single_label() { $this->assertStringNotContainsString( ' Mention::the_content( '

@alice.bsky.social

' ) ); + $this->assertStringNotContainsString( '@alice.bsky.social

' ); + $this->assertStringContainsString( 'assertCount( 1, $mention_facets ); } + + /** + * A short-form post with a body @mention still produces a #mention facet + * (not a #link facet) even though the display linkifier is active — the + * Bluesky-text builders are guarded against it. + */ + public function test_short_form_mention_stays_mention_facet_with_linkifier_active() { + \Atmosphere\Mention::init(); + + $post = self::factory()->post->create_and_get( + array( + 'post_title' => '', + 'post_content' => 'Quick note to @alice.bsky.social about the plan.', + ) + ); + + $record = ( new Post( $post ) )->transform(); + + $mention_facets = \array_filter( + $record['facets'] ?? array(), + static fn( $facet ) => 'app.bsky.richtext.facet#mention' === ( $facet['features'][0]['$type'] ?? '' ) + ); + $link_facets = \array_filter( + $record['facets'] ?? array(), + static fn( $facet ) => 'app.bsky.richtext.facet#link' === ( $facet['features'][0]['$type'] ?? '' ) + ); + + $this->assertCount( 1, $mention_facets, 'Body mention must remain a #mention facet.' ); + $this->assertCount( 0, $link_facets, 'Body mention must not become a #link facet.' ); + $this->assertStringNotContainsString( ' Date: Wed, 24 Jun 2026 20:06:33 +0200 Subject: [PATCH 06/21] Add changelog entry and docs for content @mentions --- .github/changelog/add-content-mentions | 4 ++++ readme.txt | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 .github/changelog/add-content-mentions 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/readme.txt b/readme.txt index e3cf91e..400b637 100644 --- a/readme.txt +++ b/readme.txt @@ -18,7 +18,7 @@ When you publish a post, ATmosphere automatically shares it on Bluesky and store = What you get = -* **Your posts on Bluesky, automatically.** Hit "Publish" on WordPress, and a moment later your post appears on Bluesky. Links, @-mentions, and #hashtags are detected for you. +* **Your posts on Bluesky, automatically.** Hit "Publish" on WordPress, and a moment later your post appears on Bluesky. Links, @-mentions, and #hashtags are detected for you. Mention a Bluesky account with `@handle.tld` and the mention links to their profile on your site, while they get notified on Bluesky — even on longer posts. * **Long posts done right.** A long article becomes a short, readable Bluesky thread that links back to the full piece on your site. Edits are kept tidy so existing replies and reposts on Bluesky don't get orphaned. * **Use your own domain as your Bluesky handle.** With one click, your handle becomes something like `@yourblog.com` instead of `@you.bsky.social`. ATmosphere does the technical bit; Bluesky verifies it. * **Bluesky reactions become WordPress comments.** Replies appear in your comments. Likes and reposts show up alongside them with their own counts so the engagement is visible to your readers. From 9755dd1358c659a1eb61ac9429421a8f409a436e Mon Sep 17 00:00:00 2001 From: Jeremy Herve Date: Wed, 24 Jun 2026 20:13:30 +0200 Subject: [PATCH 07/21] Document the text-render guard and pin document-content mention linking --- includes/transformer/class-base.php | 4 ++++ .../tests/transformer/class-test-document.php | 23 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/includes/transformer/class-base.php b/includes/transformer/class-base.php index a3c4cf4..b5c7b81 100644 --- a/includes/transformer/class-base.php +++ b/includes/transformer/class-base.php @@ -197,6 +197,10 @@ protected function render_post_content_plain( \WP_Post $post ): string { return $this->plain_content_cache[ $post->ID ]; } + // Suppress mention linkification on this shared plain-render path (not just Bluesky): this plain + // text feeds the Bluesky post-text composition, where an `@handle` rendered as an `` would be + // recorded as a `#link` facet (no notification) instead of a `#mention` facet (which notifies). + // The rich document / front-end render uses a separate, un-guarded `the_content` call and keeps links. $content = Mention::without_links( static fn() => \apply_filters( 'the_content', $post->post_content ) // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core WordPress filter. ); diff --git a/tests/phpunit/tests/transformer/class-test-document.php b/tests/phpunit/tests/transformer/class-test-document.php index 358889d..7966dc0 100644 --- a/tests/phpunit/tests/transformer/class-test-document.php +++ b/tests/phpunit/tests/transformer/class-test-document.php @@ -510,4 +510,27 @@ public function test_collection() { $this->assertSame( 'site.standard.document', $transformer->get_collection() ); } + + /** + * The standard.site document's rich HTML content keeps @mention links — + * the document parser path renders through the_content and is NOT covered + * by the Bluesky-text suppression guard. + */ + public function test_document_content_linkifies_mentions() { + \Atmosphere\Mention::init(); + Registry::register( new Html() ); + + $post = self::factory()->post->create_and_get( + array( 'post_content' => 'Hello @alice.bsky.social!' ) + ); + + $record = ( new Document( $post ) )->transform(); + + $this->assertArrayHasKey( 'content', $record ); + $this->assertSame( Html::TYPE, $record['content']['$type'] ); + $this->assertStringContainsString( + 'class="atmosphere-mention"', + $record['content']['html'] + ); + } } From 75c4f8812c7d8ae864c6f755ae37ccb924f0c09a Mon Sep 17 00:00:00 2001 From: Jeremy Herve Date: Thu, 25 Jun 2026 01:37:47 +0200 Subject: [PATCH 08/21] Resolve mention handles via the full DID chain, not a did:web guess Mention facets only tried the DNS TXT (_atproto.) resolution method, which is empty for every *.bsky.social handle, then fabricated did:web: on the miss. That produced a mention pointing at a non-existent profile (e.g. did:web:pevohr.bsky.social instead of the account's real did:plc), which broke the rendered mention link and suppressed Bluesky's standard.site publication preview card on the companion post. Delegate resolution to Resolver::handle_to_did(), which already does DNS TXT plus the HTTPS .well-known/atproto-did fallback that bsky.social handles actually use. When a handle resolves to neither, leave the @handle as plain text instead of minting an unroutable did:web. --- includes/transformer/class-facet.php | 75 ++++++------- .../tests/transformer/class-test-facet.php | 104 +++++++++++++++++- .../tests/transformer/class-test-post.php | 47 ++++++++ 3 files changed, 179 insertions(+), 47 deletions(-) diff --git a/includes/transformer/class-facet.php b/includes/transformer/class-facet.php index af74c1c..e64f275 100644 --- a/includes/transformer/class-facet.php +++ b/includes/transformer/class-facet.php @@ -12,6 +12,8 @@ \defined( 'ABSPATH' ) || exit; +use Atmosphere\OAuth\Resolver; + use function Atmosphere\get_connection; /** @@ -36,8 +38,9 @@ class Facet { * Broadening mention collection to the full post body resolves the same * handle more than once per publish (the carry-over detection pass and * the final {@see self::extract()} on the composed text). Memoizing the - * DNS/`did:web` result keeps that to one lookup per distinct handle per - * request, bounding duplicate DNS egress. Keyed by lowercased handle. + * resolved DID (or the empty-string miss) keeps that to one lookup per + * distinct handle per request, bounding duplicate DNS/HTTP egress. Keyed + * by lowercased handle. * * The self-handle short-circuit is intentionally evaluated outside this * cache, since it depends on the live connection option. @@ -415,25 +418,31 @@ private static function hashtags( string $text ): array { /** * Resolve a handle to a DID for mention facets. * - * Falls back to `did:web` if DNS resolution fails. The - * `is_valid_handle()` gate below ensures only DNS-syntactically - * valid handles reach `dns_get_record()` — that closes the - * "malformed handle as DNS query smuggling" angle (e.g. control - * characters or percent-encoded segments injected through a - * regex relaxation), it does NOT block lookups against - * attacker-controlled but well-formed domains. + * Resolution uses the full AT Protocol handle-resolution chain (DNS + * TXT, then the HTTPS `.well-known/atproto-did` fallback) via + * {@see Resolver::handle_to_did()}. A handle that cannot be resolved + * yields an empty string, so the mention is left as plain text rather + * than fabricating a `did:web:` — the vast majority of handles + * (anything `*.bsky.social`, for one) resolve over the well-known + * endpoint, not DNS, so a `did:web` guess is almost always wrong and + * produces a record that links to a non-existent profile. * - * That broader exposure is by design: mention resolution requires - * a DNS lookup against the mentioned handle's authoritative server, - * and any user (commenter included) can mention any well-formed - * domain. If that DNS-egress surface becomes a concern, the right - * fix is at the threat-model layer (skip mention resolution on - * the commenter path, allowlist mention authorities, or move to - * DoH with a hard timeout) rather than tightening the syntactic - * gate further. + * The `is_valid_handle()` gate ensures only DNS-syntactically valid + * handles reach resolution — that closes the "malformed handle as DNS + * query smuggling" angle (e.g. control characters or percent-encoded + * segments injected through a regex relaxation). It does NOT block + * lookups against attacker-controlled but well-formed domains; that + * broader DNS/HTTP egress is by design, since mention resolution must + * reach the mentioned handle's authoritative server (the HTTP fallback + * uses `wp_safe_remote_get()`, which rejects internal hosts). If that + * egress surface becomes a concern, the right fix is at the + * threat-model layer (skip mention resolution on the commenter path, + * allowlist mention authorities, or move to DoH with a hard timeout) + * rather than tightening the syntactic gate further. * * @param string $handle AT Protocol handle. - * @return string DID string, or empty string if the handle is malformed. + * @return string DID string, or empty string if the handle is malformed + * or cannot be resolved. */ private static function resolve_mention( string $handle ): string { $conn = get_connection(); @@ -450,38 +459,16 @@ private static function resolve_mention( string $handle ): string { return self::$resolution_cache[ $key ]; } - $did = self::resolve_handle_via_dns( $handle ); + $did = Resolver::handle_to_did( $handle ); + if ( \is_wp_error( $did ) ) { + $did = ''; + } self::$resolution_cache[ $key ] = $did; return $did; } - /** - * Resolve a syntactically-valid handle to a DID over DNS. - * - * Looks up the `_atproto.` TXT record and falls back to - * `did:web:` when no `did=` record is found. Split out of - * {@see self::resolve_mention()} so the self-handle short-circuit and the - * syntactic gate stay outside the resolution memo. - * - * @param string $handle Valid AT Protocol handle. - * @return string DID string. - */ - private static function resolve_handle_via_dns( string $handle ): string { - $records = @\dns_get_record( '_atproto.' . $handle, DNS_TXT ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged - - if ( \is_array( $records ) ) { - foreach ( $records as $record ) { - if ( ! empty( $record['txt'] ) && \str_starts_with( $record['txt'], 'did=' ) ) { - return \substr( $record['txt'], 4 ); - } - } - } - - return 'did:web:' . $handle; - } - /** * RFC 1035-style DNS-name validation, mirroring * `Resolver::is_valid_handle()`. Rejects empty strings, oversized diff --git a/tests/phpunit/tests/transformer/class-test-facet.php b/tests/phpunit/tests/transformer/class-test-facet.php index 260353d..4f12149 100644 --- a/tests/phpunit/tests/transformer/class-test-facet.php +++ b/tests/phpunit/tests/transformer/class-test-facet.php @@ -15,6 +15,53 @@ */ class Test_Facet extends WP_UnitTestCase { + /** + * Reset the request-scoped resolution memo between tests. + * + * `Facet::$resolution_cache` is static, so a DID resolved (or a miss + * cached) in one test would otherwise leak into the next. + */ + public function tear_down() { + $cache = new \ReflectionProperty( Facet::class, 'resolution_cache' ); + $cache->setAccessible( true ); + $cache->setValue( null, array() ); + + parent::tear_down(); + } + + /** + * Stub AT Protocol handle resolution over the HTTPS well-known + * endpoint so mention tests stay offline and deterministic. + * + * Handles resolve via `_atproto.` DNS first (empty for these + * fixtures) and then `https:///.well-known/atproto-did`, which + * is the request this short-circuits. A handle absent from the map (or + * mapped to an empty string) is left unresolved. + * + * @param array $map Handle => DID to return. + */ + private function mock_handle_resolution( array $map ) { + add_filter( + 'pre_http_request', + static function ( $pre, $args, $url ) use ( $map ) { + foreach ( $map as $handle => $did ) { + if ( 'https://' . $handle . '/.well-known/atproto-did' === $url ) { + return array( + 'body' => $did, + 'response' => array( + 'code' => '' === $did ? 404 : 200, + 'message' => '' === $did ? 'Not Found' : 'OK', + ), + ); + } + } + return $pre; + }, + 10, + 3 + ); + } + /** * Test extracting a URL link facet. */ @@ -43,11 +90,53 @@ public function test_extract_hashtags() { * Test extracting a mention facet. */ public function test_extract_mentions() { + $this->mock_handle_resolution( array( 'alice.bsky.social' => 'did:plc:alice' ) ); + $text = 'Hello @alice.bsky.social!'; $facets = Facet::extract( $text ); $this->assertCount( 1, $facets ); $this->assertSame( 'app.bsky.richtext.facet#mention', $facets[0]['features'][0]['$type'] ); + $this->assertSame( 'did:plc:alice', $facets[0]['features'][0]['did'] ); + } + + /** + * A handle that resolves only over the HTTPS well-known endpoint (as + * every `*.bsky.social` handle does — they have no `_atproto` DNS TXT + * record) must use the real DID it returns, never a fabricated + * `did:web:`. Regression test for the broken mention facet that + * pointed at `did:web:pevohr.bsky.social` (a dead profile) instead of + * the account's actual `did:plc:…`, which suppressed Bluesky's + * publication preview card on the companion post. + */ + public function test_mention_uses_resolved_did_not_didweb() { + $this->mock_handle_resolution( array( 'pevohr.bsky.social' => 'did:plc:sl5e4dhceock5r7f7ahnq4jm' ) ); + + $facets = Facet::extract( 'Thanks @pevohr.bsky.social!' ); + + $this->assertCount( 1, $facets ); + $this->assertSame( 'app.bsky.richtext.facet#mention', $facets[0]['features'][0]['$type'] ); + $this->assertSame( 'did:plc:sl5e4dhceock5r7f7ahnq4jm', $facets[0]['features'][0]['did'] ); + $this->assertStringStartsNotWith( 'did:web:', $facets[0]['features'][0]['did'] ); + } + + /** + * A handle that resolves via neither DNS nor the well-known endpoint + * must NOT produce a mention facet — leaving the `@handle` as plain + * text is correct, and far better than emitting a `did:web:` + * the network can't resolve. + */ + public function test_unresolvable_mention_produces_no_facet() { + $this->mock_handle_resolution( array( 'ghost.example' => '' ) ); + + $facets = Facet::extract( 'Hi @ghost.example are you there?' ); + + $mention_facets = \array_filter( + $facets, + static fn( $facet ) => 'app.bsky.richtext.facet#mention' === ( $facet['features'][0]['$type'] ?? '' ) + ); + + $this->assertCount( 0, $mention_facets ); } /** @@ -74,11 +163,18 @@ public function test_single_label_mention_produces_no_facet() { * The resolve_handles() method returns resolvable body mentions as handle => DID. */ public function test_resolve_handles_returns_resolvable_mentions() { - $handles = Facet::resolve_handles( 'Hi @alice.bsky.social and @bob.example!' ); + $this->mock_handle_resolution( + array( + 'alice.bsky.social' => 'did:plc:alice', + 'bob.example.com' => 'did:plc:bob', + ) + ); + + $handles = Facet::resolve_handles( 'Hi @alice.bsky.social and @bob.example.com!' ); $this->assertArrayHasKey( 'alice.bsky.social', $handles ); - $this->assertArrayHasKey( 'bob.example', $handles ); - $this->assertNotSame( '', $handles['alice.bsky.social'] ); + $this->assertArrayHasKey( 'bob.example.com', $handles ); + $this->assertSame( 'did:plc:alice', $handles['alice.bsky.social'] ); } /** @@ -94,6 +190,8 @@ public function test_resolve_handles_skips_single_label() { * The same handle mentioned twice appears once. */ public function test_resolve_handles_deduplicates() { + $this->mock_handle_resolution( array( 'alice.bsky.social' => 'did:plc:alice' ) ); + $handles = Facet::resolve_handles( '@alice.bsky.social then @alice.bsky.social again' ); $count = 0; diff --git a/tests/phpunit/tests/transformer/class-test-post.php b/tests/phpunit/tests/transformer/class-test-post.php index d876549..3b09ba0 100644 --- a/tests/phpunit/tests/transformer/class-test-post.php +++ b/tests/phpunit/tests/transformer/class-test-post.php @@ -11,6 +11,7 @@ use WP_UnitTestCase; use Atmosphere\Transformer\Document; +use Atmosphere\Transformer\Facet; use Atmosphere\Transformer\Post; use Atmosphere\Transformer\Publication; @@ -31,9 +32,47 @@ public function tear_down() { \remove_all_filters( 'atmosphere_transform_bsky_post' ); \remove_all_filters( 'atmosphere_post_embed' ); \remove_all_actions( 'atmosphere_long_form_strategy_downgraded' ); + + // Mention resolution memoizes into a static; clear it between tests. + $cache = new \ReflectionProperty( Facet::class, 'resolution_cache' ); + $cache->setAccessible( true ); + $cache->setValue( null, array() ); + parent::tear_down(); } + /** + * Stub AT Protocol handle resolution over the HTTPS well-known + * endpoint so mention tests stay offline and deterministic. + * + * Mirrors the helper in the Facet test: handles resolve via + * `_atproto.` DNS first (empty for these fixtures) and then + * `https:///.well-known/atproto-did`, which this short-circuits. + * + * @param array $map Handle => DID to return. + */ + private function mock_handle_resolution( array $map ) { + \add_filter( + 'pre_http_request', + static function ( $pre, $args, $url ) use ( $map ) { + foreach ( $map as $handle => $did ) { + if ( 'https://' . $handle . '/.well-known/atproto-did' === $url ) { + return array( + 'body' => $did, + 'response' => array( + 'code' => 200, + 'message' => 'OK', + ), + ); + } + } + return $pre; + }, + 10, + 3 + ); + } + /** * Encode a tiny but genuinely valid image for HTTP-fetch test bodies. * @@ -3996,6 +4035,8 @@ public function test_project_does_not_upload_blobs() { * facet so the account is notified. */ public function test_link_card_carries_body_mention_before_permalink() { + $this->mock_handle_resolution( array( 'alice.bsky.social' => 'did:plc:alice' ) ); + $post = self::factory()->post->create_and_get( array( 'post_title' => 'A normal long-form title', @@ -4024,6 +4065,8 @@ public function test_link_card_carries_body_mention_before_permalink() { * duplicated by the carry-over. */ public function test_link_card_does_not_duplicate_visible_mention() { + $this->mock_handle_resolution( array( 'alice.bsky.social' => 'did:plc:alice' ) ); + $post = self::factory()->post->create_and_get( array( 'post_title' => 'Thanks @alice.bsky.social for the help', @@ -4060,6 +4103,8 @@ public function test_link_card_without_mentions_is_unchanged() { * the terminal CTA entry, before the permalink, producing a #mention facet. */ public function test_teaser_thread_carries_body_mention_into_cta() { + $this->mock_handle_resolution( array( 'alice.bsky.social' => 'did:plc:alice' ) ); + \add_filter( 'atmosphere_long_form_composition', static fn() => 'teaser-thread' ); $post = self::factory()->post->create_and_get( @@ -4092,6 +4137,8 @@ public function test_teaser_thread_carries_body_mention_into_cta() { * Bluesky-text builders are guarded against it. */ public function test_short_form_mention_stays_mention_facet_with_linkifier_active() { + $this->mock_handle_resolution( array( 'alice.bsky.social' => 'did:plc:alice' ) ); + \Atmosphere\Mention::init(); $post = self::factory()->post->create_and_get( From 645706fe7be272369f6b5f575d22a7e3bc78a121 Mon Sep 17 00:00:00 2001 From: Jeremy Herve Date: Thu, 25 Jun 2026 02:02:34 +0200 Subject: [PATCH 09/21] Fix lint deprecation and log all dropped body mentions Remove the deprecated imagedestroy() call from the new post transformer test so composer lint passes, and move the dropped-mention debug log above the early return in carry_body_mentions() so a total drop (no handles fit) is still recorded. --- includes/transformer/class-post.php | 8 ++++---- tests/phpunit/tests/transformer/class-test-post.php | 2 -- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/includes/transformer/class-post.php b/includes/transformer/class-post.php index d818688..fd03f28 100644 --- a/includes/transformer/class-post.php +++ b/includes/transformer/class-post.php @@ -772,10 +772,6 @@ private function carry_body_mentions( string $text, string $permalink ): string $kept = $candidate; } - if ( '' === $kept ) { - return $text; - } - if ( $dropped > 0 ) { debug_log( \sprintf( @@ -787,6 +783,10 @@ private function carry_body_mentions( string $text, string $permalink ): string ); } + if ( '' === $kept ) { + return $text; + } + // Shrink the prose to fit the chosen line + permalink. $line_sep = '' !== $prose ? \mb_strlen( $sep ) : 0; $prose_budget = $max - \mb_strlen( $kept ) - $line_sep - $suffix_len; diff --git a/tests/phpunit/tests/transformer/class-test-post.php b/tests/phpunit/tests/transformer/class-test-post.php index 3b09ba0..c591e4a 100644 --- a/tests/phpunit/tests/transformer/class-test-post.php +++ b/tests/phpunit/tests/transformer/class-test-post.php @@ -98,8 +98,6 @@ private function image_bytes( string $format = 'jpeg' ): string { $encoder( $image ); $bytes = (string) \ob_get_clean(); - \imagedestroy( $image ); - return $bytes; } From 43c4270a010e0cdebdaa589941c0950f9e890ee3 Mon Sep 17 00:00:00 2001 From: Jeremy Herve Date: Thu, 25 Jun 2026 18:11:42 +0200 Subject: [PATCH 10/21] Link display mentions through the appview_url helper Route the display-side @handle.tld linkifier through appview_url() instead of a hardcoded bsky.app profile URL, so mention links honour the atmosphere_appview_host / atmosphere_appview_url filters that self-hosted appviews rely on. The default output is unchanged (bsky.app). --- includes/class-mention.php | 14 +++++++++++--- tests/phpunit/tests/class-test-mention.php | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/includes/class-mention.php b/includes/class-mention.php index be572e7..e258679 100644 --- a/includes/class-mention.php +++ b/includes/class-mention.php @@ -127,9 +127,11 @@ public static function without_links( callable $callback ): mixed { } /** - * Replace `@handle.tld` with a link to the Bluesky profile. + * Replace `@handle.tld` with a link to the appview profile. * - * No DNS: the handle goes straight into the bsky.app profile URL, which + * No DNS: the handle goes straight into the appview `profile/` + * URL (via {@see appview_url()}, so self-hosted appviews configured + * through the `atmosphere_appview_host` filter are honoured), which * resolves the handle itself. A negative lookbehind on the `@` skips the * domain half of an ActivityPub `@user@domain.tld` handle (and ordinary * email addresses) — a preceding word char, `@`, or `.` disqualifies the @@ -145,7 +147,13 @@ private static function linkify( string $text ): string { $pattern, static function ( array $m ): string { $handle = $m[1]; - $url = 'https://bsky.app/profile/' . $handle; + $url = appview_url( + 'profile/' . $handle, + array( + 'type' => 'mention', + 'handle' => $handle, + ) + ); return \sprintf( '@%s', diff --git a/tests/phpunit/tests/class-test-mention.php b/tests/phpunit/tests/class-test-mention.php index c501ec9..46645cf 100644 --- a/tests/phpunit/tests/class-test-mention.php +++ b/tests/phpunit/tests/class-test-mention.php @@ -31,6 +31,24 @@ public function test_links_bare_handle() { ); } + /** + * The profile link honours the `atmosphere_appview_host` filter so a + * self-hosted appview rewrites the mention target. + */ + public function test_links_honour_appview_host_filter() { + $filter = static fn() => 'deer.social'; + \add_filter( 'atmosphere_appview_host', $filter ); + + $out = Mention::the_content( '

Hello @alice.bsky.social!

' ); + + \remove_filter( 'atmosphere_appview_host', $filter ); + + $this->assertStringContainsString( + '@alice.bsky.social', + $out + ); + } + /** * A @mention already inside an anchor is left alone (no double-link). */ From c46b8e54f4b82e78be79b180b29fcccfa97ddd52 Mon Sep 17 00:00:00 2001 From: Jeremy Herve Date: Thu, 25 Jun 2026 18:19:26 +0200 Subject: [PATCH 11/21] Restore imagedestroy in test image helper --- tests/phpunit/tests/transformer/class-test-post.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/phpunit/tests/transformer/class-test-post.php b/tests/phpunit/tests/transformer/class-test-post.php index c591e4a..3b09ba0 100644 --- a/tests/phpunit/tests/transformer/class-test-post.php +++ b/tests/phpunit/tests/transformer/class-test-post.php @@ -98,6 +98,8 @@ private function image_bytes( string $format = 'jpeg' ): string { $encoder( $image ); $bytes = (string) \ob_get_clean(); + \imagedestroy( $image ); + return $bytes; } From 9ae65d7d3a9db3d3815406a44bbe1b533be772b3 Mon Sep 17 00:00:00 2001 From: Jeremy Herve Date: Thu, 25 Jun 2026 18:32:12 +0200 Subject: [PATCH 12/21] Address Copilot review: mention boundary guard, nested-tag unwind, test cleanup - Skip email/ActivityPub domain halves in Facet::MENTION_PATTERN so they no longer drive handle resolution or mint stray mention facets. - Unwind the linkifier tag stack to the most recent same-name tag so nested protected tags keep inner text protected. - Remove the pre_http_request stub in both Facet/Post test tear_downs and prefix the bare add_filter() call. - Revert the imagedestroy restoration (it reintroduces a PHPCS deprecation). --- includes/class-mention.php | 14 +++++++-- includes/transformer/class-facet.php | 8 ++++- tests/phpunit/tests/class-test-mention.php | 10 ++++++ .../tests/transformer/class-test-facet.php | 31 ++++++++++++++++++- .../tests/transformer/class-test-post.php | 5 +-- 5 files changed, 61 insertions(+), 7 deletions(-) diff --git a/includes/class-mention.php b/includes/class-mention.php index e258679..1faddf6 100644 --- a/includes/class-mention.php +++ b/includes/class-mention.php @@ -86,9 +86,17 @@ public static function the_content( string $content ): string { if ( \preg_match( '#^<(/)?([a-z0-9]+)\b[^>]*>$#i', $chunk, $m ) ) { $tag = \strtolower( $m[2] ); if ( '/' === $m[1] ) { - $i = \array_search( $tag, $tag_stack, true ); - if ( false !== $i ) { - $tag_stack = \array_slice( $tag_stack, 0, $i ); + /* + * 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 ) ); } } else { $tag_stack[] = $tag; diff --git a/includes/transformer/class-facet.php b/includes/transformer/class-facet.php index 193df56..0b6f32d 100644 --- a/includes/transformer/class-facet.php +++ b/includes/transformer/class-facet.php @@ -29,9 +29,15 @@ class Facet { * least two dot-separated labels, mirroring DNS-name handle syntax. * Shared by {@see self::mentions()} and {@see self::resolve_handles()}. * + * The leading `(? DID resolutions. diff --git a/tests/phpunit/tests/class-test-mention.php b/tests/phpunit/tests/class-test-mention.php index 46645cf..d0ef0d4 100644 --- a/tests/phpunit/tests/class-test-mention.php +++ b/tests/phpunit/tests/class-test-mention.php @@ -67,6 +67,16 @@ public function test_skips_code() { $this->assertSame( $html, Mention::the_content( $html ) ); } + /** + * Nested same-name protected tags unwind one level at a time, so a handle + * still inside the outer tag stays unlinked once the inner tag closes. + */ + public function test_skips_nested_same_name_protected_tags() { + $html = '

ab@alice.bsky.social

'; + + $this->assertSame( $html, Mention::the_content( $html ) ); + } + /** * The domain half of an ActivityPub @user@domain.tld handle is not linked. */ diff --git a/tests/phpunit/tests/transformer/class-test-facet.php b/tests/phpunit/tests/transformer/class-test-facet.php index 861fe07..6d17231 100644 --- a/tests/phpunit/tests/transformer/class-test-facet.php +++ b/tests/phpunit/tests/transformer/class-test-facet.php @@ -22,6 +22,9 @@ class Test_Facet extends WP_UnitTestCase { * cached) in one test would otherwise leak into the next. */ public function tear_down() { + // Drop any handle-resolution HTTP stub a test registered. + \remove_all_filters( 'pre_http_request' ); + $cache = new \ReflectionProperty( Facet::class, 'resolution_cache' ); $cache->setAccessible( true ); $cache->setValue( null, array() ); @@ -41,7 +44,7 @@ public function tear_down() { * @param array $map Handle => DID to return. */ private function mock_handle_resolution( array $map ) { - add_filter( + \add_filter( 'pre_http_request', static function ( $pre, $args, $url ) use ( $map ) { foreach ( $map as $handle => $did ) { @@ -159,6 +162,32 @@ public function test_single_label_mention_produces_no_facet() { $this->assertCount( 0, $mention_facets ); } + /** + * The domain half of an email address or an ActivityPub `@user@domain` + * handle is not a mention, so it neither resolves nor produces a facet — + * and never triggers a resolution lookup. Pins the boundary guard on + * `MENTION_PATTERN`. + */ + public function test_email_and_webfinger_domain_halves_are_not_mentions() { + // A tripwire on pre_http_request: any lookup here is a failure. + \add_filter( + 'pre_http_request', + static function () { + throw new \RuntimeException( 'Unexpected handle resolution lookup.' ); + }, + 1 + ); + + $mention_facets = static fn( string $text ) => \array_filter( + Facet::extract( $text ), + static fn( $facet ) => 'app.bsky.richtext.facet#mention' === ( $facet['features'][0]['$type'] ?? '' ) + ); + + $this->assertCount( 0, $mention_facets( 'Mail me at bob@example.com today' ) ); + $this->assertCount( 0, $mention_facets( 'Hi @pfefferle@notiz.blog there' ) ); + $this->assertSame( array(), Facet::resolve_handles( 'bob@example.com and @pfefferle@notiz.blog' ) ); + } + /** * The resolve_handles() method returns resolvable body mentions as handle => DID. */ diff --git a/tests/phpunit/tests/transformer/class-test-post.php b/tests/phpunit/tests/transformer/class-test-post.php index 3b09ba0..70036ef 100644 --- a/tests/phpunit/tests/transformer/class-test-post.php +++ b/tests/phpunit/tests/transformer/class-test-post.php @@ -33,6 +33,9 @@ public function tear_down() { \remove_all_filters( 'atmosphere_post_embed' ); \remove_all_actions( 'atmosphere_long_form_strategy_downgraded' ); + // Drop any handle-resolution HTTP stub mock_handle_resolution() set. + \remove_all_filters( 'pre_http_request' ); + // Mention resolution memoizes into a static; clear it between tests. $cache = new \ReflectionProperty( Facet::class, 'resolution_cache' ); $cache->setAccessible( true ); @@ -98,8 +101,6 @@ private function image_bytes( string $format = 'jpeg' ): string { $encoder( $image ); $bytes = (string) \ob_get_clean(); - \imagedestroy( $image ); - return $bytes; } From 4aab0d202853a28d1725aad656245bba99cc342d Mon Sep 17 00:00:00 2001 From: Jeremy Herve Date: Thu, 25 Jun 2026 19:11:33 +0200 Subject: [PATCH 13/21] Test custom Bluesky text precedence over body mention carry-over Pin the two halves of the interaction: - a mention buried only in the post body is not injected into a custom-text record (and is never even resolved), and - a mention typed directly into the custom text still produces a #mention facet so the account is notified. --- .../class-test-post-custom-text.php | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/tests/phpunit/tests/transformer/class-test-post-custom-text.php b/tests/phpunit/tests/transformer/class-test-post-custom-text.php index 52e1680..8bc54e6 100644 --- a/tests/phpunit/tests/transformer/class-test-post-custom-text.php +++ b/tests/phpunit/tests/transformer/class-test-post-custom-text.php @@ -26,9 +26,44 @@ public function tear_down() { \remove_all_filters( 'atmosphere_long_form_composition' ); \remove_all_filters( 'atmosphere_is_short_form_post' ); \remove_all_filters( 'atmosphere_transform_bsky_post' ); + \remove_all_filters( 'pre_http_request' ); + + $cache = new \ReflectionProperty( \Atmosphere\Transformer\Facet::class, 'resolution_cache' ); + $cache->setAccessible( true ); + $cache->setValue( null, array() ); + parent::tear_down(); } + /** + * Stub AT Protocol handle resolution over the HTTPS well-known endpoint so + * mention tests stay offline and deterministic. A handle absent from the + * map resolves to nothing. + * + * @param array $map Handle => DID to return. + */ + private function mock_handle_resolution( array $map ) { + \add_filter( + 'pre_http_request', + static function ( $pre, $args, $url ) use ( $map ) { + foreach ( $map as $handle => $did ) { + if ( 'https://' . $handle . '/.well-known/atproto-did' === $url ) { + return array( + 'body' => $did, + 'response' => array( + 'code' => '' === $did ? 404 : 200, + 'message' => '' === $did ? 'Not Found' : 'OK', + ), + ); + } + } + return $pre; + }, + 10, + 3 + ); + } + /** * Custom text replaces the composed body and still attaches a link card * back to the post — the link-card strategy with author-supplied prose. @@ -301,4 +336,76 @@ public function test_override_drives_projection_independently_of_meta() { $blanked->set_custom_text_override( '' ); $this->assertSame( 'link-card', $blanked->project()['strategy'] ); } + + /** + * Custom text takes precedence over the automatic composition, so a + * mention buried only in the post body is NOT carried into a custom-text + * record. Injecting a handle the author chose to omit would violate the + * "post exactly what I wrote" contract — and the body mention is never + * even resolved (a tripwire on outbound HTTP asserts no lookup happens). + * + * @covers ::transform + */ + public function test_custom_text_does_not_carry_body_only_mention() { + // Any handle-resolution lookup here is a failure: the custom-text path + // must not run the body-mention carry-over. + \add_filter( + 'pre_http_request', + static function () { + throw new \RuntimeException( 'Unexpected handle resolution lookup on the custom-text path.' ); + }, + 1 + ); + + $post = self::factory()->post->create_and_get( + array( + 'post_title' => 'A Titled Post', + 'post_content' => 'Body that quietly names @alice.bsky.social deep inside.', + ) + ); + \update_post_meta( $post->ID, ATMOSPHERE_META_CUSTOM_TEXT, 'My own words, no handles.' ); + + $record = ( new Post( $post ) )->transform(); + + $this->assertSame( 'My own words, no handles.', $record['text'] ); + $this->assertStringNotContainsString( '@alice.bsky.social', $record['text'] ); + + $mention_facets = \array_filter( + $record['facets'] ?? array(), + static fn( $facet ) => 'app.bsky.richtext.facet#mention' === ( $facet['features'][0]['$type'] ?? '' ) + ); + $this->assertCount( 0, $mention_facets, 'A body-only mention must not be carried into custom text.' ); + } + + /** + * A mention the author types directly into the custom Bluesky text still + * produces a `#mention` facet and notifies the account: facet extraction + * runs on the final record text whichever branch composed it. + * + * @covers ::transform + */ + public function test_custom_text_mention_still_produces_facet() { + $this->mock_handle_resolution( array( 'alice.bsky.social' => 'did:plc:alice' ) ); + + $post = self::factory()->post->create_and_get( + array( + 'post_title' => 'A Titled Post', + 'post_content' => 'The full blog body.', + ) + ); + \update_post_meta( $post->ID, ATMOSPHERE_META_CUSTOM_TEXT, 'Big thanks to @alice.bsky.social for this!' ); + + $record = ( new Post( $post ) )->transform(); + + $this->assertSame( 'Big thanks to @alice.bsky.social for this!', $record['text'] ); + + $mention_facets = \array_values( + \array_filter( + $record['facets'] ?? array(), + static fn( $facet ) => 'app.bsky.richtext.facet#mention' === ( $facet['features'][0]['$type'] ?? '' ) + ) + ); + $this->assertCount( 1, $mention_facets, 'A handle typed into custom text must still notify.' ); + $this->assertSame( 'did:plc:alice', $mention_facets[0]['features'][0]['did'] ); + } } From a3a2dd14dc0d917ade43da06e5b88513b3b71ae9 Mon Sep 17 00:00:00 2001 From: Jeremy Herve Date: Thu, 25 Jun 2026 19:11:49 +0200 Subject: [PATCH 14/21] Reject domain-shaped WebFinger handles; share one mention pattern Address review feedback on the mention detection: - Add a trailing boundary so '@notiz.blog@notiz.blog' is no longer mistaken for a standalone Bluesky handle (its leading half was being linked and faceted). A '.' is kept out of the boundary so a handle ending a sentence still matches. - Make Facet::MENTION_PATTERN the single source of truth and reuse it in the display linkifier, so the publish path and the front-end can no longer drift apart. - Add an 'atmosphere_link_mention' filter so a site can veto linking a handle (e.g. gate on a cached existence check), since the display linkifier intentionally does no per-render network lookup. --- includes/class-mention.php | 42 +++++++++++++++---- includes/transformer/class-facet.php | 21 ++++++---- tests/phpunit/tests/class-test-mention.php | 35 ++++++++++++++++ .../tests/transformer/class-test-facet.php | 4 ++ 4 files changed, 87 insertions(+), 15 deletions(-) diff --git a/includes/class-mention.php b/includes/class-mention.php index 1faddf6..f0f8327 100644 --- a/includes/class-mention.php +++ b/includes/class-mention.php @@ -9,6 +9,8 @@ namespace Atmosphere; +use Atmosphere\Transformer\Facet; + \defined( 'ABSPATH' ) || exit; /** @@ -140,22 +142,46 @@ public static function without_links( callable $callback ): mixed { * No DNS: the handle goes straight into the appview `profile/` * URL (via {@see appview_url()}, so self-hosted appviews configured * through the `atmosphere_appview_host` filter are honoured), which - * resolves the handle itself. A negative lookbehind on the `@` skips the - * domain half of an ActivityPub `@user@domain.tld` handle (and ordinary - * email addresses) — a preceding word char, `@`, or `.` disqualifies the - * match. + * resolves the handle itself. The shared {@see Facet::MENTION_PATTERN} + * skips the domain half of an ActivityPub `@user@domain.tld` handle (and + * ordinary email addresses) and rejects a domain-shaped WebFinger user + * half (`@notiz.blog@notiz.blog`). + * + * Existence is intentionally not checked here: a per-render lookup would + * turn every front-end page view into an outbound DNS/HTTP request to + * each mentioned domain. The publish path already gates `#mention` facets + * on real resolution ({@see Facet::resolve_mention()}), so a non-existent + * `@example.com` never notifies anyone; the display link merely points at + * the appview, which renders an unknown handle gracefully. A site that + * wants stricter display links can veto a handle through the + * `atmosphere_link_mention` filter. * * @param string $text Plain-text chunk (no tags). * @return string */ private static function linkify( string $text ): string { - $pattern = '/(? 'mention', diff --git a/includes/transformer/class-facet.php b/includes/transformer/class-facet.php index 0b6f32d..c21c79e 100644 --- a/includes/transformer/class-facet.php +++ b/includes/transformer/class-facet.php @@ -27,17 +27,24 @@ class Facet { * * Capture group 1 is the bare handle (no leading `@`). Requires at * least two dot-separated labels, mirroring DNS-name handle syntax. - * Shared by {@see self::mentions()} and {@see self::resolve_handles()}. + * This is the single source of truth for "what is a Bluesky mention": + * {@see self::mentions()}, {@see self::resolve_handles()}, and the + * display-side {@see \Atmosphere\Mention::linkify()} all share it so the + * publish path and the front-end linkifier can never drift apart. * - * The leading `(? DID resolutions. diff --git a/tests/phpunit/tests/class-test-mention.php b/tests/phpunit/tests/class-test-mention.php index d0ef0d4..9259974 100644 --- a/tests/phpunit/tests/class-test-mention.php +++ b/tests/phpunit/tests/class-test-mention.php @@ -86,6 +86,41 @@ public function test_skips_activitypub_webfinger_form() { $this->assertStringNotContainsString( 'Follow @notiz.blog@notiz.blog please

' ); + + $this->assertStringNotContainsString( ' 'ghost.example' === $handle ? false : $link; + \add_filter( 'atmosphere_link_mention', $filter, 10, 2 ); + + $out = Mention::the_content( '

Hi @ghost.example and @alice.bsky.social

' ); + + \remove_filter( 'atmosphere_link_mention', $filter, 10 ); + + // The vetoed handle stays as plain text... + $this->assertStringContainsString( '@ghost.example', $out ); + $this->assertStringNotContainsString( 'profile/ghost.example', $out ); + // ...while a handle the filter leaves alone is still linked. + $this->assertStringContainsString( + '@alice.bsky.social', + $out + ); + } + /** * An email address is not linkified. */ diff --git a/tests/phpunit/tests/transformer/class-test-facet.php b/tests/phpunit/tests/transformer/class-test-facet.php index 6d17231..c33783d 100644 --- a/tests/phpunit/tests/transformer/class-test-facet.php +++ b/tests/phpunit/tests/transformer/class-test-facet.php @@ -185,7 +185,11 @@ static function () { $this->assertCount( 0, $mention_facets( 'Mail me at bob@example.com today' ) ); $this->assertCount( 0, $mention_facets( 'Hi @pfefferle@notiz.blog there' ) ); + // A WebFinger handle whose user half is domain-shaped: the leading + // `@notiz.blog` must not be read as a standalone Bluesky handle. + $this->assertCount( 0, $mention_facets( 'Follow @notiz.blog@notiz.blog please' ) ); $this->assertSame( array(), Facet::resolve_handles( 'bob@example.com and @pfefferle@notiz.blog' ) ); + $this->assertSame( array(), Facet::resolve_handles( 'Follow @notiz.blog@notiz.blog please' ) ); } /** From 8382ebbd0285f4208773a77a9a08364abd7af805 Mon Sep 17 00:00:00 2001 From: Jeremy Herve Date: Mon, 29 Jun 2026 17:14:24 +0200 Subject: [PATCH 15/21] Address PR review: fix mention separator bug and tighten carry-over - Fix the bare-permalink branch in carry_body_mentions() to keep the `\n\n` separator before the URL. Without it a carried handle glued onto the permalink, over-extending MENTION_PATTERN and silently dropping the #mention facet. Adds a regression test for the empty-title + empty-excerpt post shape. - carry_mentions_into_teaser() now uses continue instead of break (a long handle no longer drops shorter ones after it) and logs dropped handles, matching the body-mention path. - Extend Mention::PROTECTED_TAGS with script/noscript/svg/iframe/title and match hyphenated custom elements on the tag stack. - Count graphemes (not code points) in both carry methods for consistency with the rest of the file. - Consolidate handle validation onto Resolver::is_valid_handle(), removing the drifted Facet copy that lacked the reserved-TLD check. - Cache only successful handle resolutions so a transient lookup failure can't deflate #mention facets across a bulk run. - Add @since unreleased to the new mention API surface. --- includes/class-mention.php | 13 ++++-- includes/transformer/class-facet.php | 46 ++++++++----------- includes/transformer/class-post.php | 39 ++++++++++++---- tests/phpunit/tests/class-test-mention.php | 29 ++++++++++++ .../tests/transformer/class-test-post.php | 45 ++++++++++++++++++ 5 files changed, 132 insertions(+), 40 deletions(-) diff --git a/includes/class-mention.php b/includes/class-mention.php index f0f8327..5a5ca47 100644 --- a/includes/class-mention.php +++ b/includes/class-mention.php @@ -15,6 +15,8 @@ /** * Display-side mention linkifier. + * + * @since unreleased */ class Mention { @@ -22,11 +24,14 @@ class Mention { * Tags whose text content must never be linkified. * * Mirrors the ActivityPub plugin's protected-tag set so a mention inside - * an existing link, code sample, or preformatted block is left alone. + * an existing link, code sample, or preformatted block is left alone, plus + * raw-text / non-rendered elements (`script`, `noscript`, `svg`, `iframe`, + * `title`) whose text must never become an `` — that would corrupt the + * element rather than render a link. * * @var string[] */ - private const PROTECTED_TAGS = array( 'a', 'code', 'pre', 'textarea', 'style' ); + private const PROTECTED_TAGS = array( 'a', 'code', 'pre', 'textarea', 'style', 'script', 'noscript', 'svg', 'iframe', 'title' ); /** * Whether linkification is currently suppressed. @@ -85,7 +90,7 @@ public static function the_content( string $content ): string { } // Opening / closing tag: maintain the stack, never linkify a tag. - if ( \preg_match( '#^<(/)?([a-z0-9]+)\b[^>]*>$#i', $chunk, $m ) ) { + if ( \preg_match( '#^<(/)?([a-z][a-z0-9-]*)\b[^>]*>$#i', $chunk, $m ) ) { $tag = \strtolower( $m[2] ); if ( '/' === $m[1] ) { /* @@ -174,6 +179,8 @@ static function ( array $m ): string { * text — e.g. to gate on a cached existence check or an * allowlist of known accounts. * + * @since unreleased + * * @param bool $should_link Whether to link the handle. Default true. * @param string $handle The bare handle (no leading `@`). */ diff --git a/includes/transformer/class-facet.php b/includes/transformer/class-facet.php index c21c79e..7443377 100644 --- a/includes/transformer/class-facet.php +++ b/includes/transformer/class-facet.php @@ -42,6 +42,8 @@ class Facet { * deliberately left out of the trailing class so a handle ending a * sentence (`@bsky.app.`) still matches. * + * @since unreleased + * * @var string */ public const MENTION_PATTERN = '/(? Map of handle => DID. */ @@ -461,8 +466,9 @@ private static function hashtags( string $text ): array { * endpoint, not DNS, so a `did:web` guess is almost always wrong and * produces a record that links to a non-existent profile. * - * The `is_valid_handle()` gate ensures only DNS-syntactically valid - * handles reach resolution — that closes the "malformed handle as DNS + * The {@see Resolver::is_valid_handle()} gate ensures only DNS-syntactically + * valid handles reach resolution (sharing the resolver's rules, including + * its reserved-TLD rejection) — that closes the "malformed handle as DNS * query smuggling" angle (e.g. control characters or percent-encoded * segments injected through a regex relaxation). It does NOT block * lookups against attacker-controlled but well-formed domains; that @@ -484,12 +490,12 @@ private static function resolve_mention( string $handle ): string { return $conn['did']; } - if ( ! self::is_valid_handle( $handle ) ) { + if ( ! Resolver::is_valid_handle( $handle ) ) { return ''; } $key = \strtolower( $handle ); - if ( \array_key_exists( $key, self::$resolution_cache ) ) { + if ( isset( self::$resolution_cache[ $key ] ) ) { return self::$resolution_cache[ $key ]; } @@ -498,28 +504,14 @@ private static function resolve_mention( string $handle ): string { $did = ''; } - self::$resolution_cache[ $key ] = $did; - - return $did; - } - - /** - * RFC 1035-style DNS-name validation, mirroring - * `Resolver::is_valid_handle()`. Rejects empty strings, oversized - * labels, leading/trailing hyphens, single-label hosts, and any - * character outside `[A-Za-z0-9-]` — including percent-encoded - * forms. - * - * @param string $host Handle to validate. - * @return bool - */ - private static function is_valid_handle( string $host ): bool { - if ( '' === $host || \strlen( $host ) > 253 ) { - return false; + // Cache successes only. Memoizing a miss would let a single transient + // DNS/HTTP blip suppress that handle's #mention facet across every later + // post in a long-lived WP-CLI / cron run; re-resolving a genuine miss + // (at most twice per post) is the cheaper trade. + if ( '' !== $did ) { + self::$resolution_cache[ $key ] = $did; } - $label = '[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?'; - - return (bool) \preg_match( '/^' . $label . '(?:\.' . $label . ')+$/', $host ); + return $did; } } diff --git a/includes/transformer/class-post.php b/includes/transformer/class-post.php index d997d57..2c36f5f 100644 --- a/includes/transformer/class-post.php +++ b/includes/transformer/class-post.php @@ -749,7 +749,11 @@ private function carry_body_mentions( string $text, string $permalink ): string $suffix = $sep . $permalink; $prose = \substr( $text, 0, \strlen( $text ) - \strlen( $suffix ) ); } elseif ( '' !== $permalink && $text === $permalink ) { - $suffix = $permalink; + // Carry the separator with the permalink so the mention line lands + // before it with a `\n\n` gap; without it the kept handle glues + // straight onto the URL (`@handle.tldhttps://…`), which over-extends + // MENTION_PATTERN and drops the #mention facet entirely. + $suffix = $sep . $permalink; $prose = ''; } @@ -766,13 +770,13 @@ private function carry_body_mentions( string $text, string $permalink ): string // Greedily fit handles into the room left after the permalink. A // handle that cannot fit even against an empty prose is dropped. - $suffix_len = \mb_strlen( $suffix ); + $suffix_len = grapheme_length( $suffix ); $kept = ''; $dropped = 0; foreach ( $missing as $mention ) { $candidate = '' === $kept ? $mention : $kept . ' ' . $mention; // Worst case needs a separator before the line; reserve one. - if ( \mb_strlen( $candidate ) + \mb_strlen( $sep ) + $suffix_len > $max ) { + if ( grapheme_length( $candidate ) + grapheme_length( $sep ) + $suffix_len > $max ) { ++$dropped; continue; } @@ -795,12 +799,12 @@ private function carry_body_mentions( string $text, string $permalink ): string } // Shrink the prose to fit the chosen line + permalink. - $line_sep = '' !== $prose ? \mb_strlen( $sep ) : 0; - $prose_budget = $max - \mb_strlen( $kept ) - $line_sep - $suffix_len; + $line_sep = '' !== $prose ? grapheme_length( $sep ) : 0; + $prose_budget = $max - grapheme_length( $kept ) - $line_sep - $suffix_len; if ( $prose_budget <= 0 ) { $prose = ''; - } elseif ( \mb_strlen( $prose ) > $prose_budget ) { + } elseif ( grapheme_length( $prose ) > $prose_budget ) { $prose = truncate_text( $prose, $prose_budget ); } @@ -843,17 +847,32 @@ private function carry_mentions_into_teaser( array $texts ): array { $last = \count( $texts ) - 1; $cta = $texts[ $last ]; $sep = "\n\n"; - $room = self::BLUESKY_MAX_GRAPHEMES - \mb_strlen( $cta ) - \mb_strlen( $sep ); + $room = self::BLUESKY_MAX_GRAPHEMES - grapheme_length( $cta ) - grapheme_length( $sep ); - $kept = ''; + $kept = ''; + $dropped = 0; foreach ( $missing as $mention ) { $candidate = '' === $kept ? $mention : $kept . ' ' . $mention; - if ( \mb_strlen( $candidate ) > $room ) { - break; + // Skip this handle rather than stop: a longer handle may not fit + // where a later, shorter one still would. + if ( grapheme_length( $candidate ) > $room ) { + ++$dropped; + continue; } $kept = $candidate; } + if ( $dropped > 0 ) { + debug_log( + \sprintf( + 'post %d: %d body mention(s) dropped from the Bluesky teaser thread — no room within the %d-character limit', + $this->object->ID, + $dropped, + self::BLUESKY_MAX_GRAPHEMES + ) + ); + } + if ( '' === $kept ) { return $texts; } diff --git a/tests/phpunit/tests/class-test-mention.php b/tests/phpunit/tests/class-test-mention.php index 9259974..b9c006a 100644 --- a/tests/phpunit/tests/class-test-mention.php +++ b/tests/phpunit/tests/class-test-mention.php @@ -67,6 +67,35 @@ public function test_skips_code() { $this->assertSame( $html, Mention::the_content( $html ) ); } + /** + * A @mention inside a raw-text / non-rendered element (e.g.