Files
multica/packages/views/editor/utils/preprocess-links.test.ts
Bohan Jiang 0f36c88855 fix(markdown): don't auto-link bare filenames as external URLs (#4245)
* 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>
2026-06-17 17:06:09 +08:00

139 lines
6.0 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 #1623https://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.mdexample.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",
);
});
});