From 9ade3fa834ce698247693ecd05dd604081b32fbc Mon Sep 17 00:00:00 2001
From: Jeremy Herve
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.