mirror of
https://github.com/discourse/discourse.git
synced 2026-08-05 18:57:20 -05:00
FIX: support CJK and spaceless scripts in watched word boundaries (#37844)
Watched words failed to match in CJK (Chinese, Japanese, Korean) and other spaceless scripts because word boundary detection relied on whitespace or non-word characters. Languages like Chinese don't use spaces between words, so "测试" inside "这是一个测试文本" was never matched. Introduce a SPACELESS_SCRIPTS constant covering Han, Hiragana, Katakana, Hangul, Thai, Lao, Myanmar, Khmer, and Tibetan Unicode ranges. Update `match_word_regexp` for both Ruby and JS engines so that characters from these scripts are treated as word boundaries. This allows a CJK watched word to match when surrounded by other CJK characters, and a Latin watched word to match when adjacent to CJK text (e.g., "Test" in "我的Test很好"), while still preventing partial Latin matches (e.g., "Testing" does not match "Test"). Also fix the admin watched word testing modal to use `RegExp.exec()` with capture group extraction instead of `String.match()`, since the new boundary patterns include a leading consuming group. Remove the outdated "non-chrome browsers do not support lookbehind" comment — all major browsers have supported lookbehind since 2023. https://meta.discourse.org/t/71288 https://meta.discourse.org/t/396109
This commit is contained in:
@@ -61,7 +61,6 @@ class WordWatcher
|
||||
|
||||
# This regexp is run in miniracer, and the client JS app
|
||||
# Make sure it is compatible with major browsers when changing
|
||||
# hint: non-chrome browsers do not support 'lookbehind'
|
||||
def self.compiled_regexps_for_action(action, engine: :ruby, raise_errors: false)
|
||||
words = cached_words_for_action(action)
|
||||
return [] if words.blank?
|
||||
@@ -283,13 +282,28 @@ class WordWatcher
|
||||
|
||||
private_class_method :censor_text_with_regexp
|
||||
|
||||
# Returns a regexp that transforms a regular expression into a regular
|
||||
# expression that matches a whole word.
|
||||
SPACELESS_SCRIPTS = {
|
||||
"Han" => "\\u4E00-\\u9FFF\\u3400-\\u4DBF",
|
||||
"Hiragana" => "\\u3040-\\u309F",
|
||||
"Katakana" => "\\u30A0-\\u30FF",
|
||||
"Hangul" => "\\uAC00-\\uD7AF",
|
||||
"Thai" => "\\u0E00-\\u0E7F",
|
||||
"Lao" => "\\u0E80-\\u0EFF",
|
||||
"Myanmar" => "\\u1000-\\u109F",
|
||||
"Khmer" => "\\u1780-\\u17FF",
|
||||
"Tibetan" => "\\u0F00-\\u0FFF",
|
||||
}.values.join
|
||||
|
||||
def self.match_word_regexp(regexp, engine: :ruby)
|
||||
s = SPACELESS_SCRIPTS
|
||||
if engine == :js
|
||||
"(?:\\P{L}|^)(#{regexp})(?=\\P{L}|$)"
|
||||
leading = "(?:[\\P{L}#{s}]|^|(?=[#{s}]))"
|
||||
trailing = "(?:(?=[\\P{L}#{s}]|$)|(?<=[#{s}]))"
|
||||
"#{leading}(#{regexp})#{trailing}"
|
||||
elsif engine == :ruby
|
||||
"(?:[^[:word:]]|^)(#{regexp})(?=[^[:word:]]|$)"
|
||||
leading = "(?:(?<![[:word:]&&[^#{s}]])|(?=[#{s}]))"
|
||||
trailing = "(?:(?![[:word:]&&[^#{s}]])|(?<=[#{s}]))"
|
||||
"#{leading}(#{regexp})#{trailing}"
|
||||
else
|
||||
raise "unknown regexp engine: #{engine}"
|
||||
end
|
||||
|
||||
@@ -106,7 +106,10 @@ export default class WatchedWordTesting extends Component {
|
||||
options.case_sensitive ? "gu" : "gui"
|
||||
);
|
||||
|
||||
matches.push(...(this.value.match(wordRegexp) || []));
|
||||
let match;
|
||||
while ((match = wordRegexp.exec(this.value)) !== null) {
|
||||
matches.push(match[1] || match[0]);
|
||||
}
|
||||
} catch {
|
||||
hasCompiledExpressionError = true;
|
||||
}
|
||||
|
||||
@@ -80,10 +80,14 @@ RSpec.describe WordWatcher do
|
||||
SiteSetting.watched_words_regular_expressions = false
|
||||
regexps = described_class.compiled_regexps_for_action(:block)
|
||||
|
||||
s = WordWatcher::SPACELESS_SCRIPTS
|
||||
leading = "(?:(?<![[:word:]&&[^#{s}]])|(?=[#{s}]))"
|
||||
trailing = "(?:(?![[:word:]&&[^#{s}]])|(?<=[#{s}]))"
|
||||
|
||||
expect(regexps).to be_an(Array)
|
||||
expect(regexps.map(&:inspect)).to contain_exactly(
|
||||
"/(?:[^[:word:]]|^)(#{word1}|#{word2})(?=[^[:word:]]|$)/i",
|
||||
"/(?:[^[:word:]]|^)(#{word3}|#{word4})(?=[^[:word:]]|$)/",
|
||||
"/#{leading}(#{word1}|#{word2})#{trailing}/i",
|
||||
"/#{leading}(#{word3}|#{word4})#{trailing}/",
|
||||
)
|
||||
end
|
||||
|
||||
@@ -126,7 +130,11 @@ RSpec.describe WordWatcher do
|
||||
it "works correctly when regular expressions are disabled" do
|
||||
regexps = described_class.compiled_regexps_for_action(:block)
|
||||
expect(regexps).to be_an(Array)
|
||||
expect(regexps).to contain_exactly(/(?:[^[:word:]]|^)(\S*abc)(?=[^[:word:]]|$)/i)
|
||||
expect(regexps.size).to eq(1)
|
||||
expect(regexps.first).to match("xyzabc")
|
||||
expect(regexps.first).to match(" abc")
|
||||
expect(regexps.first).to match("testabc")
|
||||
expect(regexps.first).not_to match("abcdef")
|
||||
end
|
||||
|
||||
it "skips invalid watched words when regular expression are enabled" do
|
||||
@@ -257,6 +265,56 @@ RSpec.describe WordWatcher do
|
||||
).to eq("love")
|
||||
end
|
||||
|
||||
it "handles CJK characters as word boundaries" do
|
||||
Fabricate(:watched_word, word: "测试", action: WatchedWord.actions[:require_approval])
|
||||
|
||||
expect(described_class.new("测试").word_matches_for_action?(:require_approval)[1]).to eq("测试")
|
||||
expect(
|
||||
described_class.new("这是一个测试文本").word_matches_for_action?(:require_approval)[1],
|
||||
).to eq("测试")
|
||||
expect(
|
||||
described_class.new("hello 测试 world").word_matches_for_action?(:require_approval)[1],
|
||||
).to eq("测试")
|
||||
expect(described_class.new("API测试结果").word_matches_for_action?(:require_approval)[1]).to eq(
|
||||
"测试",
|
||||
)
|
||||
end
|
||||
|
||||
it "handles Latin watched words adjacent to CJK text" do
|
||||
Fabricate(:watched_word, word: "Test", action: WatchedWord.actions[:require_approval])
|
||||
|
||||
expect(
|
||||
described_class.new("我的Test很好").word_matches_for_action?(:require_approval)[1],
|
||||
).to eq("Test")
|
||||
expect(
|
||||
described_class.new("Testing").word_matches_for_action?(:require_approval),
|
||||
).to be_falsey
|
||||
end
|
||||
|
||||
it "handles CJK boundaries with the JS engine" do
|
||||
Fabricate(:watched_word, word: "测试", action: WatchedWord.actions[:require_approval])
|
||||
|
||||
regexps = described_class.compiled_regexps_for_action(:require_approval, engine: :js)
|
||||
expect(regexps.size).to eq(1)
|
||||
|
||||
re = regexps.first
|
||||
expect(re).to match("测试")
|
||||
expect(re).to match("这是一个测试文本")
|
||||
expect(re).to match("hello 测试 world")
|
||||
expect(re).to match("API测试结果")
|
||||
end
|
||||
|
||||
it "handles Latin words adjacent to CJK text with the JS engine" do
|
||||
Fabricate(:watched_word, word: "Test", action: WatchedWord.actions[:require_approval])
|
||||
|
||||
regexps = described_class.compiled_regexps_for_action(:require_approval, engine: :js)
|
||||
expect(regexps.size).to eq(1)
|
||||
|
||||
re = regexps.first
|
||||
expect(re).to match("我的Test很好")
|
||||
expect(re).not_to match("Testing")
|
||||
end
|
||||
|
||||
context "when there are multiple matches" do
|
||||
context "with non regexp words" do
|
||||
it "lists all matching words" do
|
||||
|
||||
Reference in New Issue
Block a user