FIX: Do not replace words in hashtags and mentions (#14760)

FIX: Do not replace words in hashtags and mentions (#14760)

Watched words were replaced inside mentions and hashtags when watched word regular expressions were enabled.

diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/watched-words.js b/app/assets/javascripts/pretty-text/engines/discourse-markdown/watched-words.js
index 2bcffba..d82e13f 100644
--- a/app/assets/javascripts/pretty-text/engines/discourse-markdown/watched-words.js
+++ b/app/assets/javascripts/pretty-text/engines/discourse-markdown/watched-words.js
@@ -31,6 +31,14 @@ function findAllMatches(text, matchers) {
   return matches.sort((a, b) => a.index - b.index);
 }
 
+// We need this to load after mentions and hashtags which are priority 0
+export const priority = 1;
+
+const NONE = 0;
+const MENTION = 1;
+const HASHTAG_LINK = 2;
+const HASHTAG_SPAN = 3;
+
 export function setup(helper) {
   const opts = helper.getOptions();
 
@@ -77,6 +85,39 @@ export function setup(helper) {
 
         let htmlLinkLevel = 0;
 
+        // We scan once to mark tokens that must be skipped because they are
+        // mentions or hashtags
+        let lastType = NONE;
+        for (let i = 0; i < tokens.length; ++i) {
+          const currentToken = tokens[i];
+
+          if (currentToken.type === "mention_open") {
+            lastType = MENTION;
+          } else if (
+            (currentToken.type === "link_open" ||
+              currentToken.type === "span_open") &&
+            currentToken.attrs &&
+            currentToken.attrs.some(
+              (attr) => attr[0] === "class" && attr[1] === "hashtag"
+            )
+          ) {
+            lastType =
+              currentToken.type === "link_open" ? HASHTAG_LINK : HASHTAG_SPAN;
+          }
+
+          if (lastType !== NONE) {
+            currentToken.skipReplace = true;
+          }
+
+          if (
+            (lastType === MENTION && currentToken.type === "mention_close") ||
+            (lastType === HASHTAG_LINK && currentToken.type === "link_close") ||
+            (lastType === HASHTAG_SPAN && currentToken.type === "span_close")
+          ) {
+            lastType = NONE;
+          }
+        }
+
         // We scan from the end, to keep position when new tags added.
         // Use reversed logic in links start/end match
         for (let i = tokens.length - 1; i >= 0; i--) {
@@ -105,6 +146,11 @@ export function setup(helper) {
             }
           }
 
+          // Skip content of mentions or hashtags
+          if (currentToken.skipReplace) {
+            continue;
+          }
+
           if (currentToken.type === "text") {
             const text = currentToken.content;
             const matches = (cache[text] =
@@ -121,14 +167,6 @@ export function setup(helper) {
                 continue;
               }
 
-              if (
-                matches[ln].index > 0 &&
-                (text[matches[ln].index - 1] === "@" ||
-                  text[matches[ln].index - 1] === "#")
-              ) {
-                continue;
-              }
-
               if (matches[ln].index > lastPos) {
                 token = new state.Token("text", "", 0);
                 token.content = text.slice(lastPos, matches[ln].index);
diff --git a/spec/components/pretty_text_spec.rb b/spec/components/pretty_text_spec.rb
index 7acb5e0..d4b7267 100644
--- a/spec/components/pretty_text_spec.rb
+++ b/spec/components/pretty_text_spec.rb
@@ -1479,6 +1479,22 @@ HTML
       HTML
     end
 
+    it "does not replace hashtags and mentions when watched words are regular expressions" do
+      SiteSetting.watched_words_regular_expressions = true
+
+      Fabricate(:user, username: "test")
+      category = Fabricate(:category, slug: "test")
+      Fabricate(:watched_word, action: WatchedWord.actions[:replace], word: "es", replacement: "discourse")
+
+      expect(PrettyText.cook("@test #test test")).to match_html(<<~HTML)
+        <p>
+          <a class="mention" href="/u/test">@test</a>
+          <a class="hashtag" href="/c/test/#{category.id}">#<span>test</span></a>
+          tdiscourset
+        </p>
+      HTML
+    end
+
     it "supports overlapping words" do
       Fabricate(:watched_word, action: WatchedWord.actions[:link], word: "meta", replacement: "https://meta.discourse.org")
       Fabricate(:watched_word, action: WatchedWord.actions[:replace], word: "iz", replacement: "is")

GitHub sha: 19ef6995a87489daf41dabf6e0f73a02869d6e87

This commit appears in #14760 which was approved by eviltrout. It was merged by nbianca.