diff --git a/.github/changelog/add-content-mentions b/.github/changelog/add-content-mentions
new file mode 100644
index 0000000..2cda5d4
--- /dev/null
+++ b/.github/changelog/add-content-mentions
@@ -0,0 +1,4 @@
+Significance: minor
+Type: added
+
+Mention a Bluesky account with @handle.tld in your post: the mention now links to their profile on your site, and they are notified on Bluesky even on longer posts.
diff --git a/includes/class-atmosphere.php b/includes/class-atmosphere.php
index 7fb4d5e..543d26e 100644
--- a/includes/class-atmosphere.php
+++ b/includes/class-atmosphere.php
@@ -118,6 +118,13 @@ public function init(): void {
\add_action( 'init', array( Options::class, 'init' ), 5 );
\add_action( 'init', array( Settings_Fields::class, 'init' ), 5 );
+ /*
+ * Display-side @handle.tld mention auto-linking. Self-registers on
+ * init so the_content (priority 100) is wired for both front-end
+ * rendering and the site.standard.document content parsers.
+ */
+ \add_action( 'init', array( Mention::class, 'init' ), 5 );
+
/*
* Seed the long-form composition strategy from the user's
* setting. Priority 1 so any downstream filter at the default
diff --git a/includes/class-mention.php b/includes/class-mention.php
new file mode 100644
index 0000000..eecf68f
--- /dev/null
+++ b/includes/class-mention.php
@@ -0,0 +1,376 @@
+` — that would corrupt the
+ * element rather than render a link.
+ *
+ * @var string[]
+ */
+ private const PROTECTED_TAGS = array( 'a', 'code', 'pre', 'textarea', 'style', 'script', 'noscript', 'svg', 'iframe', 'title' );
+
+ /**
+ * Whether linkification is currently suppressed.
+ *
+ * The transformer renders post content through `the_content` to compose
+ * the Bluesky post text. Linkifying there would turn a plain `@handle`
+ * into an ``, which the post-text builder records as a `#link` facet
+ * (no notification) instead of a `#mention` facet (notifies). The builders
+ * wrap their `the_content` calls in {@see self::without_links()} so this
+ * guard short-circuits the filter for that path only.
+ *
+ * @var bool
+ */
+ private static bool $suppressed = false;
+
+ /**
+ * Register hooks.
+ *
+ * @return void
+ */
+ public static function init(): void {
+ /*
+ * Priority 100: after the ActivityPub plugin's mention filter (99).
+ * A `@user@domain.tld` webfinger handle it has already wrapped in an
+ * anchor is then skipped here (protected `` tag) rather than
+ * double-linked. The negative lookbehind in self::linkify() makes the
+ * coexistence robust even when ActivityPub is not installed.
+ */
+ \add_filter( 'the_content', array( self::class, 'the_content' ), 100 );
+ }
+
+ /**
+ * Linkify bare `@handle.tld` mentions in rendered HTML.
+ *
+ * @param string $content Rendered HTML.
+ * @return string
+ */
+ public static function the_content( string $content ): string {
+ if ( self::$suppressed || '' === $content ) {
+ return $content;
+ }
+
+ // Bound work on pathological input, mirroring ActivityPub's guard.
+ if ( \strlen( $content ) > MB_IN_BYTES ) {
+ return $content;
+ }
+
+ return self::walk(
+ $content,
+ // Linkify a text chunk only when no protected tag is open.
+ static fn( string $text, bool $in_protected, string $prev_char ): string
+ => $in_protected ? $text : self::linkify( $text, $prev_char )
+ );
+ }
+
+ /**
+ * Classify the `@handle.tld` mentions in rendered HTML by whether the
+ * front end would linkify them.
+ *
+ * Returns `array( 'linkable' => …, 'protected' => … )`:
+ *
+ * - `linkable`: a map of lowercased handle => first-seen handle for every
+ * mention the display linkifier ({@see self::the_content()}) would turn
+ * into a profile link, in first-appearance order. The publish path resolves
+ * and carries exactly this set, so a Bluesky `#mention` (and its
+ * notification) is only ever minted for a handle the site itself links.
+ * - `protected`: the set of lowercased handles that appear *only* inside a
+ * protected region (a ``/`` sample or an existing ``).
+ * The transformer subtracts `linkable` from this to build the deny-set it
+ * passes to {@see Facet::extract()}, so a handle buried in a code sample
+ * never mints a `#mention` facet the front end leaves as plain text — even
+ * when it leaks into the excerpt.
+ *
+ * Shares the {@see self::walk()} tokenizer (and therefore the exact
+ * protected-tag rules and cross-tag boundary handling) with the display
+ * linkifier, so publish and display can never disagree about what is a
+ * mention. Returns empty sets for empty or pathologically large content,
+ * mirroring {@see self::the_content()}, which linkifies neither.
+ *
+ * @since unreleased
+ *
+ * @param string $content Rendered HTML.
+ * @return array{linkable:array
`) popping the first
+ * match would drop the still-open outer tag and linkify
+ * text that is in fact still protected.
+ */
+ $keys = \array_keys( $tag_stack, $tag, true );
+ if ( ! empty( $keys ) ) {
+ $tag_stack = \array_slice( $tag_stack, 0, \end( $keys ) );
+ }
+ } elseif ( ! self::is_self_closing( $chunk ) ) {
+ /*
+ * Push opening tags only. A self-closed tag (``,
+ * ``) opens and closes in a single chunk, so
+ * pushing it would leave a phantom protected tag on the
+ * stack that is never popped — suppressing linkification for
+ * every mention after it in the render.
+ */
+ $tag_stack[] = $tag;
+ }
+ $out .= $chunk;
+ continue;
+ }
+
+ $in_protected = (bool) \array_intersect( $tag_stack, self::PROTECTED_TAGS );
+ $out .= $on_text( $chunk, $in_protected, $prev_char );
+
+ // Advance the boundary past unprotected text only; protected text
+ // is elided from the linkified stream, so a handle right after a
+ // protected region still sees the character before that region.
+ if ( ! $in_protected && '' !== $chunk ) {
+ $prev_char = \mb_substr( $chunk, -1 );
+ }
+ }
+
+ return $out;
+ }
+
+ /**
+ * The synthetic prefix that reproduces a cross-tag mention boundary.
+ *
+ * {@see Facet::MENTION_PATTERN}'s leading `(?`) can't be mistaken for the tag's own self-closing slash.
+ * The tag then counts as self-closing only when the trailing `/` sits right
+ * after the tag name (``) or is separated from the last attribute by
+ * whitespace, a quote, or `=` (``, ``). A `/` glued
+ * to an *unquoted* attribute value (``) is part
+ * of that value, so the element actually stays open and its `@handle`
+ * content remains protected.
+ *
+ * @param string $tag Full start-tag chunk, e.g. ``.
+ * @return bool
+ */
+ private static function is_self_closing( string $tag ): bool {
+ $bare = (string) \preg_replace( '/"[^"]*"|\'[^\']*\'/', '', $tag );
+
+ return (bool) \preg_match( '#(?:^<[a-z][a-z0-9-]*|[\s"\'=])/\s*>$#i', $bare );
+ }
+
+ /**
+ * 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 appview profile.
+ *
+ * No DNS: the handle goes straight into the appview `profile/……` sample or an
+ * existing ``), which the front end leaves as plain text. Passing `null`
+ * (the default) blocks nothing.
+ *
+ * @param string $text Plain text.
+ * @param bool $with_mentions Whether to resolve and emit `#mention` facets. Default true.
+ * @param array` sample
+ * or an existing `` link is *not*. Otherwise publish and display
+ * would disagree: a handle the linkifier leaves as plain text would still be
+ * carried into the Bluesky post and mint a `#mention` facet + notification.
+ *
+ * In projection mode the preview must not resolve mentions over DNS (see
+ * {@see self::$projecting}), but the carry-over still lengthens the composed
+ * record at publish. So the preview returns the *syntactic* linkable
+ * handles (no lookups) with empty DIDs — an upper bound, since some may not
+ * resolve — so the carry-over sizing, and therefore the reported grapheme
+ * count, never under-reports the record the publisher will write.
+ *
+ * @return array`/`` sample or an existing `` link) and nowhere the
+ * front end would linkify. {@see Facet::extract()} skips these, so a handle
+ * buried in a code sample never notifies anyone even when it leaks into the
+ * excerpt — keeping every record's `#mention` facets in lockstep with what
+ * the site's own page renders as a link.
+ *
+ * @return array
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). + */ + public function test_skips_existing_anchor() { + $html = ''; + + $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 ) );
+ }
+
+ /**
+ * A @mention inside a raw-text / non-rendered element (e.g.