mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-15 22:29:04 +02:00
* fix(markdown): don't auto-link bare filenames as external URLs Agent comments that mention a project file like `plan.md` were turned into clickable links to https://plan.md (dead external site). linkify-it fuzzy detection matches `plan.md` as a domain because its extension is also a valid TLD (md = Moldova; likewise io, sh, rs, py). Suppress schemeless (fuzzy) linkify matches whose token is a bare filename (single segment ending in a known source/config extension). Explicit schemes (`https://plan.md`) and real domains (`example.com`) are unaffected. The file extension list is now shared between the file-path and bare-filename detectors so they can't drift. Fixes #4222 Co-authored-by: multica-agent <github@multica.ai> * docs(markdown): drop inaccurate .io example from bare-filename comment io is not in the FILE_EXTENSIONS list, so .io domains are never suppressed. Listing it as an example was misleading. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
139 lines
6.0 KiB
TypeScript
139 lines
6.0 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
||
import { preprocessLinks } from "@multica/ui/markdown/linkify";
|
||
|
||
// The bug: linkify-it does not treat CJK full-width punctuation as a URL
|
||
// boundary, so the href can swallow trailing punctuation and the Chinese
|
||
// characters that follow it (up to the next space). The fix truncates the
|
||
// detected URL at the first CJK full-width punctuation character.
|
||
|
||
describe("preprocessLinks — CJK punctuation boundary", () => {
|
||
it("stops URL at ideographic full stop 。", () => {
|
||
const out = preprocessLinks("见 https://example.com/path。然后继续");
|
||
expect(out).toBe("见 [https://example.com/path](https://example.com/path)。然后继续");
|
||
});
|
||
|
||
it("stops URL at fullwidth comma ,", () => {
|
||
const out = preprocessLinks("打开 https://example.com/a,以及其他");
|
||
expect(out).toBe("打开 [https://example.com/a](https://example.com/a),以及其他");
|
||
});
|
||
|
||
it("stops URL at ideographic comma 、", () => {
|
||
const out = preprocessLinks("两个地址 https://a.com/x、https://b.com/y");
|
||
expect(out).toBe(
|
||
"两个地址 [https://a.com/x](https://a.com/x)、[https://b.com/y](https://b.com/y)",
|
||
);
|
||
});
|
||
|
||
it("stops URL at fullwidth right paren )", () => {
|
||
const out = preprocessLinks("(见 https://example.com/x)后文");
|
||
expect(out).toBe("(见 [https://example.com/x](https://example.com/x))后文");
|
||
});
|
||
|
||
it("stops URL at corner bracket 」", () => {
|
||
const out = preprocessLinks("「https://example.com/a」后文");
|
||
expect(out).toBe("「[https://example.com/a](https://example.com/a)」后文");
|
||
});
|
||
|
||
it("stops URL at fullwidth exclamation !", () => {
|
||
const out = preprocessLinks("太好了 https://example.com/x!继续");
|
||
expect(out).toBe("太好了 [https://example.com/x](https://example.com/x)!继续");
|
||
});
|
||
|
||
it("handles the original bug report (PR link then 。 then more text)", () => {
|
||
const out = preprocessLinks(
|
||
"已合并 PR #1623:https://github.com/multica-ai/multica/pull/1623。merge commit",
|
||
);
|
||
expect(out).toBe(
|
||
"已合并 PR #1623:[https://github.com/multica-ai/multica/pull/1623](https://github.com/multica-ai/multica/pull/1623)。merge commit",
|
||
);
|
||
});
|
||
|
||
it("does not swallow the entire remainder when there is no trailing space", () => {
|
||
const out = preprocessLinks("https://github.com/x/y/issues/1619。我接下来把这个");
|
||
expect(out).toBe(
|
||
"[https://github.com/x/y/issues/1619](https://github.com/x/y/issues/1619)。我接下来把这个",
|
||
);
|
||
});
|
||
|
||
it("preserves ASCII trailing period handling (no regression)", () => {
|
||
const out = preprocessLinks("visit https://example.com/path. next.");
|
||
expect(out).toBe("visit [https://example.com/path](https://example.com/path). next.");
|
||
});
|
||
|
||
it("preserves plain URL with no trailing punctuation (no regression)", () => {
|
||
const out = preprocessLinks("go https://example.com/path");
|
||
expect(out).toBe("go [https://example.com/path](https://example.com/path)");
|
||
});
|
||
|
||
it("preserves CJK letters inside URL path (only trims on punctuation)", () => {
|
||
const out = preprocessLinks("https://zh.wikipedia.org/wiki/中国 参考");
|
||
expect(out).toBe(
|
||
"[https://zh.wikipedia.org/wiki/中国](https://zh.wikipedia.org/wiki/中国) 参考",
|
||
);
|
||
});
|
||
|
||
it("does not re-link an already-linked URL that contains 。", () => {
|
||
// If a user or upstream already wrote [text](url。), we leave it alone.
|
||
const input = "见 [link](https://example.com/x。)后文";
|
||
expect(preprocessLinks(input)).toBe(input);
|
||
});
|
||
|
||
it("does not linkify fuzzy domains inside existing markdown link labels", () => {
|
||
const input =
|
||
"数据来源:[NBA.com Schedule](https://www.nba.com/schedule)、[NBC Insider](https://www.nbc.com/nbc-insider/every-nba-playoff-game-this-week-on-nbc-peacock-april-25-28)";
|
||
|
||
expect(preprocessLinks(input)).toBe(input);
|
||
});
|
||
|
||
it("still linkifies fuzzy domains outside existing markdown links", () => {
|
||
const input = "数据来源:[NBA.com Schedule](https://www.nba.com/schedule),官网 NBA.com";
|
||
|
||
expect(preprocessLinks(input)).toBe(
|
||
"数据来源:[NBA.com Schedule](https://www.nba.com/schedule),官网 [NBA.com](http://NBA.com)",
|
||
);
|
||
});
|
||
});
|
||
|
||
// The bug (#4222): an agent mentions a project file like `plan.md` in a comment.
|
||
// linkify-it fuzzy-matches it as the domain `plan.md` (md is Moldova's ccTLD) and
|
||
// turns it into a clickable https://plan.md link that goes nowhere. Bare filename
|
||
// tokens must stay plain text — only an explicit scheme makes them a link.
|
||
describe("preprocessLinks — bare filenames are not auto-linked as URLs", () => {
|
||
it("leaves a bare .md filename in CJK prose as plain text", () => {
|
||
const out = preprocessLinks("决策已锁定,plan.md 已更新");
|
||
expect(out).toBe("决策已锁定,plan.md 已更新");
|
||
});
|
||
|
||
it("leaves README.md as plain text", () => {
|
||
expect(preprocessLinks("see README.md for details")).toBe("see README.md for details");
|
||
});
|
||
|
||
it("leaves other extensions that collide with TLDs (sh, rs, py) as plain text", () => {
|
||
expect(preprocessLinks("run build.sh then main.rs and app.py")).toBe(
|
||
"run build.sh then main.rs and app.py",
|
||
);
|
||
});
|
||
|
||
it("honors an explicit scheme on a filename-shaped host", () => {
|
||
expect(preprocessLinks("open https://plan.md now")).toBe(
|
||
"open [https://plan.md](https://plan.md) now",
|
||
);
|
||
});
|
||
|
||
it("still linkifies real fuzzy domains whose TLD is not a file extension", () => {
|
||
expect(preprocessLinks("官网 NBA.com")).toBe("官网 [NBA.com](http://NBA.com)");
|
||
});
|
||
|
||
it("suppresses the bare filename but still linkifies a real domain after it", () => {
|
||
expect(preprocessLinks("plan.md,example.com")).toBe(
|
||
"plan.md,[example.com](http://example.com)",
|
||
);
|
||
});
|
||
|
||
it("still detects explicit ./ file paths (FILE_PATH_REGEX regression)", () => {
|
||
expect(preprocessLinks("see ./src/main.go here")).toBe(
|
||
"see [./src/main.go](./src/main.go) here",
|
||
);
|
||
});
|
||
});
|