diff --git a/apps/core/markdown.py b/apps/core/markdown.py index 3bdd1c3..cdf1b56 100644 --- a/apps/core/markdown.py +++ b/apps/core/markdown.py @@ -1,8 +1,12 @@ +import xml.etree.ElementTree as etree from html import escape from html.parser import HTMLParser from urllib.parse import urlparse import markdown as md_lib +from markdown.extensions import Extension +from markdown.inlinepatterns import InlineProcessor +from markdown.util import AtomicString ALLOWED_TAGS = { @@ -65,8 +69,42 @@ def _append_starttag(self, tag, attrs): self.parts.append(f'<{tag}{"".join(clean_attrs)}>') +_BARE_URL_RE = r'https?://[^\s<>]+' +_BARE_URL_TRAILING_PUNCTUATION = '.,;:!?\'"' + + +class _BareUrlInlineProcessor(InlineProcessor): + """Turn bare URLs (not already part of `[text](url)` or `` markup) into links.""" + + def handleMatch(self, m, data): + url = m.group(0) + end = m.end(0) + # Trailing punctuation is usually sentence punctuation, not part of the URL. + # An unbalanced closing paren is treated the same way, so a URL in + # "(see https://example.com)" doesn't swallow the closing paren. + while url and ( + url[-1] in _BARE_URL_TRAILING_PUNCTUATION + or (url[-1] == ')' and url.count('(') < url.count(')')) + ): + url = url[:-1] + end -= 1 + if not url: + return None, None, None + el = etree.Element('a') + el.set('href', url) + el.text = AtomicString(url) + return el, m.start(0), end + + +class _AutolinkExtension(Extension): + def extendMarkdown(self, md): + # Priority 75 runs after 'link'/'autolink'/'html' (which claim URLs already + # wrapped in markdown or HTML link syntax) but before emphasis patterns. + md.inlinePatterns.register(_BareUrlInlineProcessor(_BARE_URL_RE, md), 'autolink_bare_url', 75) + + def render_markdown(text: str) -> str: - html = md_lib.markdown(text or '', extensions=['fenced_code', 'tables']) + html = md_lib.markdown(text or '', extensions=['fenced_code', 'tables', _AutolinkExtension()]) sanitizer = _MarkdownSanitizer() sanitizer.feed(html) sanitizer.close() diff --git a/apps/entries/templates/entries/partials/_markdown_cheatsheet.html b/apps/entries/templates/entries/partials/_markdown_cheatsheet.html index ebbe73c..ac1483a 100644 --- a/apps/entries/templates/entries/partials/_markdown_cheatsheet.html +++ b/apps/entries/templates/entries/partials/_markdown_cheatsheet.html @@ -1,7 +1,7 @@ {% comment %} Markdown quick-reference shown beside the entry form. Keep rows in sync with apps/core/markdown.py (render_markdown): -fenced_code + tables, headings h1-h3, no images. +fenced_code + tables, headings h1-h3, no images, bare URLs auto-link. {% endcomment %}
@@ -27,6 +27,10 @@

Markdown cheat sheet

Link
[text](https://…)
+
+
Auto-link
+
https://…
+
Bullet list
- item
diff --git a/tests/test_entries.py b/tests/test_entries.py index 89a18f8..224ff2d 100644 --- a/tests/test_entries.py +++ b/tests/test_entries.py @@ -427,6 +427,35 @@ def test_empty_description_returns_empty(self, client, user): resp = client.post(reverse('entries:markdown-preview'), {'description': ''}) assert resp.status_code == 200 + def test_autolinks_bare_url(self, client, user): + client.force_login(user) + resp = client.post(reverse('entries:markdown-preview'), { + 'description': 'See https://cds.cern.ch/record/2967685 for details.', + }) + assert resp.status_code == 200 + assert ( + b'' + b'https://cds.cern.ch/record/2967685' in resp.content + ) + + def test_autolink_does_not_swallow_trailing_punctuation(self, client, user): + client.force_login(user) + resp = client.post(reverse('entries:markdown-preview'), { + 'description': 'Visit https://example.com, then https://example.com/other.', + }) + assert resp.status_code == 200 + assert b'https://example.com,' in resp.content + assert b'https://example.com/other.' in resp.content + + def test_autolink_does_not_duplicate_existing_markdown_link(self, client, user): + client.force_login(user) + resp = client.post(reverse('entries:markdown-preview'), { + 'description': '[the paper](https://example.com/already)', + }) + assert resp.status_code == 200 + assert resp.content.count(b'the paper' in resp.content + # ── Entry description templates ───────────────────────────────────────────────