From 19ef6995a87489daf41dabf6e0f73a02869d6e87 Mon Sep 17 00:00:00 2001 From: Bianca Nenciu Date: Fri, 29 Oct 2021 17:53:09 +0300 Subject: [PATCH] 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. --- .../discourse-markdown/watched-words.js | 54 ++++++++++++++++--- spec/components/pretty_text_spec.rb | 16 ++++++ 2 files changed, 62 insertions(+), 8 deletions(-) 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 2bcffbaf0bc..d82e13f8d03 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 7acb5e0f3b5..d4b7267291f 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) +

+ @test + #test + tdiscourset +

+ 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")