]*>[\s\S]*?<\/a>|
]*>|]*>[\s\S]*?<\/pre>)/g,m=>{_al_stash.push(m);return `\x00B${_al_stash.length-1}\x00`;});
- s=s.replace(/(https?:\/\/[^\s<>"'\)\]]+)/g,(url)=>{
- // Strip trailing punctuation that was likely not part of the URL
- const trail=url.match(/[.,;:!?)]$/)?url.slice(-1):'';
+ s=s.replace(/(https?:\/\/[^\s<>"')\]\uFF09]+)/g,(url)=>{
+ // Strip trailing punctuation that was likely not part of the URL.
+ // CJK full-width punctuation ()。,;:!?、) is included because LLMs
+ // frequently use full-width delimiters in Chinese/Japanese text.
+ const trail=url.match(/[.,;:!?)]$/)||url.match(/[\uFF09\uFF0C\uFF1B\uFF1A\uFF01\uFF1F\u3001\u3002]$/)?url.slice(-1):'';
const clean=trail?url.slice(0,-1):url;
return `${esc(clean)}${trail}`;
});
diff --git a/tests/test_fullwidth_autolink_behaviour.py b/tests/test_fullwidth_autolink_behaviour.py
new file mode 100644
index 00000000..77af263d
--- /dev/null
+++ b/tests/test_fullwidth_autolink_behaviour.py
@@ -0,0 +1,203 @@
+"""Behavioural tests for the CJK full-width punctuation autolink fix (#6792).
+
+These tests drive the ACTUAL renderMd() in static/ui.js via node — the same
+harness used by test_renderer_js_behaviour.py — so they exercise the real
+production autolink passes (inlineMd + outer paragraph pass) instead of a
+test-owned copy of the regex.
+
+Bug: the autolink regex only excluded ASCII ')' (U+0029) from URL matches.
+When a URL appeared before a CJK full-width right parenthesis )(U+FF09) —
+extremely common in Chinese LLM output like "(或 https://lmarena.ai)" —
+the )was swallowed into the link.
+
+Fix: add \uFF09 to the regex exclusion set AND to the trailing-punctuation
+strip. Also covers full-width comma/period/colon/semicolon/exclamation/
+question mark/ideographic comma for completeness.
+
+Every case asserts BOTH directions:
+- the punctuation never enters href
+- the punctuation remains visible after the closing
+"""
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+REPO_ROOT = Path(__file__).parent.parent.resolve()
+UI_JS_PATH = REPO_ROOT / "static" / "ui.js"
+
+NODE = shutil.which("node")
+
+pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH")
+
+
+_DRIVER_SRC = r"""
+const fs = require('fs');
+const src = fs.readFileSync(process.argv[2], 'utf8');
+global.window = {};
+global.document = { createElement: () => ({ innerHTML: '', textContent: '' }), baseURI: 'http://localhost/app/' };
+function _sessionUrlForSid(sid) { return '/app/session/' + encodeURIComponent(String(sid || '')); }
+const esc = s => String(s ?? '').replace(/[&<>"']/g, c => (
+ {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
+const _IMAGE_EXTS=/\.(png|jpg|jpeg|gif|webp|bmp|ico|avif)$/i;
+const _SVG_EXTS=/\.svg$/i;
+const _AUDIO_EXTS=/\.(mp3|ogg|wav|m4a|aac|flac|wma|opus|webm)$/i;
+const _VIDEO_EXTS=/\.(mp4|webm|mkv|mov|avi|ogv|m4v)$/i;
+// Minimal stand-in for ui.js' _inlineMediaHtmlForRef used when the driver
+// extracts only renderMd(). Mirrors the live UI for the cases the existing
+// tests assert against (https image, bare file:// image).
+function _inlineMediaHtmlForRef(ref){
+ const r = String(ref || '');
+ if (/^https?:\/\//.test(r)) return `
`;
+ if (/^file:\/\//.test(r)){
+ const m = r.replace(/^file:\/\//i, '');
+ return `
`;
+ }
+ return `
`;
+}
+
+function extractFunc(name) {
+ const re = new RegExp('function\\s+' + name + '\\s*\\(');
+ const start = src.search(re);
+ if (start < 0) throw new Error(name + ' not found');
+ let i = src.indexOf('{', start);
+ let depth = 1; i++;
+ while (depth > 0 && i < src.length) {
+ if (src[i] === '{') depth++;
+ else if (src[i] === '}') depth--;
+ i++;
+ }
+ return src.slice(start, i);
+}
+eval(extractFunc('_matchBacktickFenceLine'));
+eval(extractFunc('_isBacktickFenceClose'));
+eval(extractFunc('renderMd'));
+
+let buf = '';
+process.stdin.on('data', c => { buf += c; });
+process.stdin.on('end', () => { process.stdout.write(renderMd(buf)); });
+"""
+
+
+@pytest.fixture(scope="module")
+def driver_path(tmp_path_factory):
+ """Write the node driver to a tmp file (works around `node -e` arg quirks)."""
+ p = tmp_path_factory.mktemp("autolink_driver") / "driver.js"
+ p.write_text(_DRIVER_SRC, encoding="utf-8")
+ return str(p)
+
+
+def _render(driver_path, markdown: str) -> str:
+ """Run renderMd against the actual ui.js and return the rendered HTML."""
+ result = subprocess.run(
+ [NODE, driver_path, str(UI_JS_PATH)],
+ input=markdown,
+ capture_output=True,
+ text=True,
+ timeout=30,
+ )
+ if result.returncode != 0:
+ raise RuntimeError(f"node driver failed: {result.stderr}")
+ return result.stdout
+
+
+# Every CJK full-width mark the fix adds to the trailing-punctuation strip:
+# )(U+FF09) ,(U+FF0C) 。(U+3002) ;(U+FF1B) :(U+FF1A) !(U+FF01) ?(U+FF1F) 、(U+3001)
+CJK_TRAILING_MARKS = [")", ",", "。", ";", ":", "!", "?", "、"]
+# ASCII punctuation the existing autolink strip already handled (regression guard).
+ASCII_TRAILING_MARKS = [")", ",", ".", ";", ":", "!", "?"]
+
+
+class TestAutolinkOuterParagraphPass:
+ """Plain-paragraph URLs go through the OUTER autolink pass in renderMd()."""
+
+ @pytest.mark.parametrize("mark", CJK_TRAILING_MARKS)
+ def test_cjk_trailing_mark_stripped_and_visible_after_anchor(self, driver_path, mark):
+ out = _render(driver_path, f"See https://example.com{mark}")
+ assert 'href="https://example.com"' in out, (
+ f"CJK mark {mark!r} must not enter href. Got: {out!r}"
+ )
+ assert f'href="https://example.com{mark}"' not in out, (
+ f"CJK mark {mark!r} leaked into href. Got: {out!r}"
+ )
+ assert f"{mark}" in out, (
+ f"CJK mark {mark!r} must stay visible after