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:
Régis Hanol
2026-02-16 18:04:27 +01:00
committed by GitHub
parent ced45ec71e
commit d7a53ada16
3 changed files with 84 additions and 9 deletions
+19 -5
View File
@@ -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;
}
+61 -3
View File
@@ -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