fix(tui): stop bare URLs at CJK punctuation (#10934)

This PR:
- Stop bare URLs at CJK punctuation, preserving the following text.
- Leave CJK characters without punctuation, explicit Markdown links, and
angle autolinks unchanged.
- Add regression tests.

Fixes #10933

Before:

<img width="846" height="198" alt="screenshot-2026-10-14_17-13-48"
src="https://github.com/user-attachments/assets/be2d8283-ce3f-4a79-988b-28cb48d268d9"
/>


After:

<img width="776" height="188" alt="screenshot-2026-10-14_17-14-14"
src="https://github.com/user-attachments/assets/b827498c-2446-441e-9d8f-ff24398e3eda"
/>
This commit is contained in:
xu0o0 2026-10-28 17:11:40 +08:00 • committed by GitHub
parent e509d7edc7
commit fa16851520
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 56 additions and 3 deletions

View file

@ -3,11 +3,21 @@ import markedCjkFriendly from "marked-cjk-friendly";
import { renderLatex } from "../latex.ts";
import { getCapabilities, hyperlink, isImageLine } from "../terminal-image.ts";
import type { Component } from "../tui.ts";
import { applyBackgroundToLine, flattenLines, visibleWidth, wrapTextWithAnsi } from "../utils.ts";
import { applyBackgroundToLine, cjkPunctuationRegex, flattenLines, visibleWidth, wrapTextWithAnsi } from "../utils.ts";
const STRICT_STRIKETHROUGH_REGEX = /^(~~)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/;
class StrictStrikethroughTokenizer extends Tokenizer {
class MarkdownTokenizer extends Tokenizer {
override url(src: string): Tokens.Link | undefined {
const token = super.url(src);
if (!token) return undefined;
// Bare URLs stop at CJK punctuation; explicit links and angle autolinks
// use separate tokenizers and can still contain these characters.
const punctuationIndex = token.raw.search(cjkPunctuationRegex);
return punctuationIndex < 0 ? token : super.url(token.raw.slice(0, punctuationIndex));
}
override del(src: string): Tokens.Del | undefined {
const match = STRICT_STRIKETHROUGH_REGEX.exec(src);
if (!match) {
@ -189,7 +199,7 @@ function trimPartialClosingFences(tokens: readonly Token[]): void {
const markdownParser = new Marked();
markdownParser.setOptions({
tokenizer: new StrictStrikethroughTokenizer(),
tokenizer: new MarkdownTokenizer(),
});
markdownParser.use({
tokenizer: { emStrong: markedCjkFriendly().tokenizer?.emStrong },

View file

@ -1716,6 +1716,49 @@ bar`,
resetCapabilitiesCache();
});
it("should stop bare URLs at CJK punctuation and preserve the following text", () => {
setCapabilities({ images: null, trueColor: false, hyperlinks: true });
for (const punctuation of "。,;:!?、“”‘’()【】《》「」『』…—·・〈〉[]{}.。「」、・") {
for (const url of ["https://example.com", "https://example.com/中文?q=搜索#片段", "www.example.com"]) {
const source = `${url}${punctuation}后面的中文`;
const output = new Markdown(source, 0, 0, defaultMarkdownTheme).render(160).join("");
const href = url.startsWith("www.") ? `http://${url}` : url;
assert.ok(output.includes(`\x1b]8;;${href}\x1b\\`), source);
assert.ok(stripAnsi(output).includes(`\x1b]8;;\x1b\\${punctuation}后面的中文`), source);
assert.strictEqual(stripAnsi(output.replace(/\x1b\]8;;[^\x1b]*\x1b\\/g, "")).trimEnd(), source);
}
}
});
it("should preserve Chinese characters without punctuation in bare URLs", () => {
setCapabilities({ images: null, trueColor: false, hyperlinks: true });
for (const url of [
"https://example.com紧接着中文",
"https://example.com/中文?q=搜索#片段",
"https://example.com/テストー한글",
]) {
const output = new Markdown(url, 0, 0, defaultMarkdownTheme).render(160).join("");
assert.ok(output.includes(`\x1b]8;;${url}\x1b\\`));
}
});
it("should preserve CJK punctuation in explicit links and angle autolinks", () => {
setCapabilities({ images: null, trueColor: false, hyperlinks: true });
const url = "https://example.com/中文,路径·テスト・한글…—。";
for (const source of [`[文档](${url})`, `<${url}>`]) {
const output = new Markdown(source, 0, 0, defaultMarkdownTheme).render(160).join("");
assert.ok(output.includes(`\x1b]8;;${url}\x1b\\`), source);
}
});
it("should preserve English punctuation trimming before Chinese punctuation", () => {
setCapabilities({ images: null, trueColor: false, hyperlinks: true });
const source = "https://example.com.,后面的中文";
const output = new Markdown(source, 0, 0, defaultMarkdownTheme).render(160).join("");
assert.ok(output.includes("\x1b]8;;https://example.com\x1b\\"));
assert.ok(stripAnsi(output).includes("\x1b]8;;\x1b\\.,后面的中文"));
});
it("should not duplicate URL for autolinked emails", () => {
// Hyperlinks capability does not affect the mailto: display check.
setCapabilities({ images: null, trueColor: false, hyperlinks: false });