mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop): ignore mentions in Markdown code
Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
parent
1d8d006aaf
commit
62c53b3aa1
@@ -5,6 +5,129 @@ function escapeRegExp(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function maskRange(
|
||||
chars: string[],
|
||||
text: string,
|
||||
start: number,
|
||||
end: number,
|
||||
): void {
|
||||
for (let index = start; index < end; index += 1) {
|
||||
if (text[index] !== "\n" && text[index] !== "\r") chars[index] = " ";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace Markdown code with spaces while retaining offsets and line endings.
|
||||
* Handles fenced blocks, four-space/tab-indented lines, and backtick code spans.
|
||||
*/
|
||||
function maskMarkdownCode(text: string): string {
|
||||
const chars = text.split("");
|
||||
const lines: Array<{ start: number; end: number; content: string }> = [];
|
||||
|
||||
let lineStart = 0;
|
||||
while (lineStart < text.length) {
|
||||
let lineEnd = lineStart;
|
||||
while (
|
||||
lineEnd < text.length &&
|
||||
text[lineEnd] !== "\n" &&
|
||||
text[lineEnd] !== "\r"
|
||||
) {
|
||||
lineEnd += 1;
|
||||
}
|
||||
lines.push({
|
||||
start: lineStart,
|
||||
end: lineEnd,
|
||||
content: text.slice(lineStart, lineEnd),
|
||||
});
|
||||
if (text[lineEnd] === "\r" && text[lineEnd + 1] === "\n") lineEnd += 1;
|
||||
lineStart = lineEnd + 1;
|
||||
}
|
||||
|
||||
let fence: { marker: string; length: number } | null = null;
|
||||
for (const line of lines) {
|
||||
if (fence) {
|
||||
maskRange(chars, text, line.start, line.end);
|
||||
const closing = line.content.match(/^ {0,3}(`+|~+)[ \t]*$/);
|
||||
if (
|
||||
closing &&
|
||||
closing[1][0] === fence.marker &&
|
||||
closing[1].length >= fence.length
|
||||
) {
|
||||
fence = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const opening = line.content.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
|
||||
if (opening && !(opening[1][0] === "`" && opening[2].includes("`"))) {
|
||||
fence = { marker: opening[1][0], length: opening[1].length };
|
||||
maskRange(chars, text, line.start, line.end);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^(?: {4}|\t)/.test(line.content)) {
|
||||
maskRange(chars, text, line.start, line.end);
|
||||
}
|
||||
}
|
||||
|
||||
const isMasked = (index: number) =>
|
||||
chars[index] === " " && text[index] !== " ";
|
||||
const isEscaped = (index: number) => {
|
||||
let slashCount = 0;
|
||||
for (
|
||||
let cursor = index - 1;
|
||||
cursor >= 0 && text[cursor] === "\\";
|
||||
cursor -= 1
|
||||
) {
|
||||
slashCount += 1;
|
||||
}
|
||||
return slashCount % 2 === 1;
|
||||
};
|
||||
|
||||
for (let index = 0; index < text.length; ) {
|
||||
if (text[index] !== "`" || isMasked(index) || isEscaped(index)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let openerEnd = index + 1;
|
||||
while (
|
||||
openerEnd < text.length &&
|
||||
text[openerEnd] === "`" &&
|
||||
!isMasked(openerEnd)
|
||||
) {
|
||||
openerEnd += 1;
|
||||
}
|
||||
const delimiterLength = openerEnd - index;
|
||||
let closer = openerEnd;
|
||||
|
||||
while (closer < text.length) {
|
||||
if (text[closer] !== "`" || isMasked(closer)) {
|
||||
closer += 1;
|
||||
continue;
|
||||
}
|
||||
let closerEnd = closer + 1;
|
||||
while (
|
||||
closerEnd < text.length &&
|
||||
text[closerEnd] === "`" &&
|
||||
!isMasked(closerEnd)
|
||||
) {
|
||||
closerEnd += 1;
|
||||
}
|
||||
if (closerEnd - closer === delimiterLength) {
|
||||
maskRange(chars, text, index, closerEnd);
|
||||
index = closerEnd;
|
||||
break;
|
||||
}
|
||||
closer = closerEnd;
|
||||
}
|
||||
|
||||
if (closer >= text.length) index = openerEnd;
|
||||
}
|
||||
|
||||
return chars.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether `text` contains an @mention of `name`.
|
||||
*
|
||||
@@ -23,7 +146,7 @@ export function getMentionOffset(text: string, name: string): number | null {
|
||||
`(^|\\s|\\(|[*_]{1,3}|\\|\\|)(@${escaped})(?=\\|\\||[\\s,;.!?:)\\]}*_]|$)`,
|
||||
"i",
|
||||
);
|
||||
const match = pattern.exec(text);
|
||||
const match = pattern.exec(maskMarkdownCode(text));
|
||||
return match ? match.index + match[1].length : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { hasMention } from "./hasMention.ts";
|
||||
import { getMentionOffset, hasMention } from "./hasMention.ts";
|
||||
|
||||
// ── Plain @mention ────────────────────────────────────────────────────
|
||||
|
||||
@@ -94,3 +94,44 @@ test("does not false-positive on partial name match", () => {
|
||||
// "Al" should not match inside "@Alice"
|
||||
assert.equal(hasMention("@Alice", "Al"), false);
|
||||
});
|
||||
|
||||
// ── Markdown code ─────────────────────────────────────────────────────
|
||||
|
||||
test("ignores mentions in inline code", () => {
|
||||
assert.equal(hasMention("run `notify @Alice now`", "Alice"), false);
|
||||
assert.equal(hasMention("run ``notify `x` @Alice``", "Alice"), false);
|
||||
});
|
||||
|
||||
test("ignores mentions in fenced code blocks", () => {
|
||||
assert.equal(
|
||||
hasMention("before\n```ts\nnotify(@Alice)\n```\nafter", "Alice"),
|
||||
false,
|
||||
);
|
||||
assert.equal(hasMention("~~~\r\n@Alice\r\n~~~", "Alice"), false);
|
||||
});
|
||||
|
||||
test("ignores mentions in indented code blocks", () => {
|
||||
assert.equal(hasMention("before\n @Alice\nafter", "Alice"), false);
|
||||
assert.equal(hasMention("before\n\t@Alice\nafter", "Alice"), false);
|
||||
});
|
||||
|
||||
test("still matches prose mentions around code", () => {
|
||||
assert.equal(hasMention("`@Alice` then @Alice", "Alice"), true);
|
||||
assert.equal(hasMention("```\n@Alice\n```\n@Alice", "Alice"), true);
|
||||
assert.equal(hasMention(" @Alice\n@Alice", "Alice"), true);
|
||||
});
|
||||
|
||||
test("preserves the original offset after masked code", () => {
|
||||
const text = "`@Alice` then @Alice";
|
||||
assert.equal(getMentionOffset(text, "Alice"), text.lastIndexOf("@Alice"));
|
||||
});
|
||||
|
||||
test("does not treat escaped or unclosed backticks as code", () => {
|
||||
assert.equal(hasMention("\\` @Alice", "Alice"), true);
|
||||
assert.equal(hasMention("` @Alice", "Alice"), true);
|
||||
});
|
||||
|
||||
test("requires matching inline-code delimiter lengths", () => {
|
||||
assert.equal(hasMention("`` @Alice ` still code ``", "Alice"), false);
|
||||
assert.equal(hasMention("`` @Alice `", "Alice"), true);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user