diff --git a/devplacepy/rendering.py b/devplacepy/rendering.py
index daf055bb..753bf3ba 100644
--- a/devplacepy/rendering.py
+++ b/devplacepy/rendering.py
@@ -79,6 +79,7 @@ _EMAIL_RE = re.compile(r"\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b")
_EMAIL_KEEP_DOMAIN = "molodetz.nl"
_MEDIA_SKIP_TAGS = {"a", "code", "pre"}
+_TRAILING_PUNCT_RE = re.compile(r"[.,;:!?)\]}\"']+$")
_TITLE_INLINE_TAGS = {
"b", "strong", "i", "em", "code", "del", "s", "mark", "sub", "sup", "span", "br",
}
@@ -139,6 +140,11 @@ def _alt_from_url(url: str) -> str:
def _embed_url(url: str) -> str:
+ trail = ""
+ punct_match = _TRAILING_PUNCT_RE.search(url)
+ if punct_match:
+ trail = punct_match.group()
+ url = url[: punct_match.start()]
youtube = _YOUTUBE_RE.search(url)
if youtube:
video_id = youtube.group(1)
@@ -146,19 +152,19 @@ def _embed_url(url: str) -> str:
f'
"
+ f"{trail}"
)
escaped = html.escape(url, quote=True)
if _IMAGE_RE.search(url):
alt = html.escape(_alt_from_url(url), quote=True)
- return f'
'
+ return f'
{trail}'
if _VIDEO_RE.search(url):
- return f''
+ return f'{trail}'
if _AUDIO_RE.search(url):
- return f''
+ return f'{trail}'
return (
f''
- f"{html.escape(url)}"
+ f"{html.escape(url)}{trail}"
)
diff --git a/devplacepy/static/js/ContentRenderer.js b/devplacepy/static/js/ContentRenderer.js
index 67282015..1a4eca35 100644
--- a/devplacepy/static/js/ContentRenderer.js
+++ b/devplacepy/static/js/ContentRenderer.js
@@ -188,17 +188,21 @@ export class ContentRenderer {
a.textContent = "@" + part.username;
fragment.appendChild(a);
} else {
- const el = this.urlToEmbed(part.value);
+ const { cleaned, trail } = this.stripTrailingPunct(part.value);
+ const el = this.urlToEmbed(cleaned);
if (el) {
fragment.appendChild(el);
} else {
const a = document.createElement("a");
- a.href = part.value;
+ a.href = cleaned;
a.target = "_blank";
a.rel = "noopener noreferrer";
- a.textContent = part.value;
+ a.textContent = cleaned;
fragment.appendChild(a);
}
+ if (trail) {
+ fragment.appendChild(document.createTextNode(trail));
+ }
}
}
@@ -282,6 +286,14 @@ export class ContentRenderer {
return null;
}
+ stripTrailingPunct(url) {
+ const m = url.match(/[.,;:!?)\]}\"']+$/);
+ if (m) {
+ return { cleaned: url.slice(0, m.index), trail: m[0] };
+ }
+ return { cleaned: url, trail: "" };
+ }
+
walkNodes(root, callback) {
const skipTags = new Set(["CODE", "PRE", "A", "IFRAME", "IMG", "VIDEO", "SCRIPT", "STYLE"]);
const iter = document.createNodeIterator(root, NodeFilter.SHOW_TEXT, null, false);
diff --git a/tests/unit/rendering.py b/tests/unit/rendering.py
index d5ba187b..a4c1d06c 100644
--- a/tests/unit/rendering.py
+++ b/tests/unit/rendering.py
@@ -263,3 +263,46 @@ def test_xss_legitimate_link_survives_audit():
out = str(render_content("see https://example.com/page ok"))
assert 'href="https://example.com/page"' in out
assert_no_executable_html(out)
+
+
+def test_bare_url_with_trailing_period():
+ out = str(render_content("see https://example.com/page."))
+ assert 'href="https://example.com/page"' in out
+ assert "." in out
+ assert "https://example.com/page." not in out
+
+
+def test_bare_url_with_trailing_comma():
+ out = str(render_content("check https://example.com/page,"))
+ assert 'href="https://example.com/page"' in out
+ assert "," in out
+
+
+def test_bare_url_with_trailing_exclamation():
+ out = str(render_content("look https://example.com/page!"))
+ assert 'href="https://example.com/page"' in out
+ assert "!" in out
+
+
+def test_bare_url_with_trailing_question_mark():
+ out = str(render_content("did you see https://example.com/page?"))
+ assert 'href="https://example.com/page"' in out
+ assert "?" in out
+
+
+def test_bare_url_with_trailing_paren():
+ out = str(render_content("see (https://example.com/page)"))
+ assert 'href="https://example.com/page"' in out
+ assert ")" in out
+
+
+def test_bare_url_with_trailing_multiple_punctuation():
+ out = str(render_content("visit https://example.com/page..."))
+ assert 'href="https://example.com/page"' in out
+ assert "..." in out
+
+
+def test_bare_url_without_trailing_punctuation_unchanged():
+ out = str(render_content("see https://example.com/page ok"))
+ assert 'href="https://example.com/page"' in out
+ assert "https://example.com/page " in out