Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
- parse_mentions_and_notify now sends email to mentioned user (separate from forum subscription emails — fires on every mention) - parse_forum_markdown accepts current_user_name; mentions matching the viewer get extra .forum-mention-self class - topic.html passes current_user.name to filter; .forum-mention-self styled with amber background + bold + ring Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
168 lines
5.5 KiB
Python
168 lines
5.5 KiB
Python
"""
|
|
Simple Markdown Parser for Forum
|
|
================================
|
|
|
|
Converts basic markdown to safe HTML.
|
|
Supports: bold, italic, code, links, auto-links, lists, quotes, @mentions
|
|
"""
|
|
|
|
import re
|
|
from markupsafe import Markup, escape
|
|
|
|
|
|
def _autolink(text):
|
|
"""Convert bare URLs to clickable links. Works on escaped text before HTML wrapping."""
|
|
return re.sub(
|
|
r'https?://[^\s<]+',
|
|
lambda m: f'<a href="{m.group(0)}" target="_blank" rel="noopener noreferrer" class="forum-link">{m.group(0)}</a>',
|
|
text
|
|
)
|
|
|
|
|
|
def parse_forum_markdown(text, current_user_name=None):
|
|
"""
|
|
Convert markdown text to safe HTML.
|
|
|
|
Supported syntax:
|
|
- **bold** or __bold__
|
|
- *italic* or _italic_
|
|
- `inline code`
|
|
- [link text](url)
|
|
- bare https://... URLs (auto-linked)
|
|
- - list items
|
|
- > quotes
|
|
- @mentions (highlighted)
|
|
"""
|
|
if not text:
|
|
return Markup('')
|
|
|
|
# Normalize line endings (Windows \r\n -> \n)
|
|
text = text.replace('\r\n', '\n').replace('\r', '\n')
|
|
|
|
# Escape HTML first for security
|
|
text = str(escape(text))
|
|
|
|
# Apply inline formatting BEFORE block structure
|
|
# This ensures URLs inside list items get linked
|
|
|
|
# Code blocks (``` ... ```)
|
|
text = re.sub(
|
|
r'```(.*?)```',
|
|
r'<pre class="forum-code-block"><code>\1</code></pre>',
|
|
text,
|
|
flags=re.DOTALL
|
|
)
|
|
|
|
# Inline code (`code`)
|
|
text = re.sub(r'`([^`]+)`', r'<code class="forum-code">\1</code>', text)
|
|
|
|
# Bold (**text** or __text__)
|
|
text = re.sub(r'\*\*([^*]+)\*\*', r'<strong>\1</strong>', text)
|
|
text = re.sub(r'__([^_]+)__', r'<strong>\1</strong>', text)
|
|
|
|
# Italic (*text* or _text_) - careful not to match bold
|
|
text = re.sub(r'(?<!\*)\*([^*]+)\*(?!\*)', r'<em>\1</em>', text)
|
|
text = re.sub(r'(?<!_)_([^_]+)_(?!_)', r'<em>\1</em>', text)
|
|
|
|
# Links [text](url) - only allow http/https
|
|
def safe_link(match):
|
|
link_text = match.group(1)
|
|
url = match.group(2)
|
|
if url.startswith(('http://', 'https://', '/')):
|
|
return f'<a href="{url}" target="_blank" rel="noopener noreferrer" class="forum-link">{link_text}</a>'
|
|
return match.group(0)
|
|
|
|
text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', safe_link, text)
|
|
|
|
# Auto-link bare URLs (after [text](url) to avoid doubling)
|
|
text = re.sub(
|
|
r'(?<!href=")(?<!">)https?://[^\s<]+',
|
|
lambda m: f'<a href="{m.group(0)}" target="_blank" rel="noopener noreferrer" class="forum-link">{m.group(0)}</a>',
|
|
text
|
|
)
|
|
|
|
# @mentions - highlight them; mark self-mentions with extra class
|
|
self_variants = set()
|
|
if current_user_name:
|
|
norm = current_user_name.strip().lower()
|
|
self_variants = {norm.replace(' ', '.'), norm.replace(' ', '_'), norm.replace(' ', '')}
|
|
|
|
def _render_mention(m):
|
|
handle = m.group(1).lower()
|
|
cls = 'forum-mention forum-mention-self' if handle in self_variants else 'forum-mention'
|
|
return f'<span class="{cls}">@{m.group(1)}</span>'
|
|
|
|
text = re.sub(r'@([\w.\-]+)', _render_mention, text)
|
|
|
|
# Now process block structure (lists, quotes, paragraphs)
|
|
lines = text.split('\n')
|
|
result_lines = []
|
|
in_list = False
|
|
in_quote = False
|
|
|
|
for line in lines:
|
|
stripped = line.strip()
|
|
|
|
# Empty line = paragraph break
|
|
if not stripped:
|
|
if in_list:
|
|
result_lines.append('</ul>')
|
|
in_list = False
|
|
if in_quote:
|
|
result_lines.append('</blockquote>')
|
|
in_quote = False
|
|
result_lines.append('<br>')
|
|
continue
|
|
|
|
# Quote blocks (> text) — > because already escaped
|
|
if stripped.startswith('> '):
|
|
if not in_quote:
|
|
result_lines.append('<blockquote class="forum-quote">')
|
|
in_quote = True
|
|
result_lines.append(stripped[5:])
|
|
continue
|
|
elif in_quote:
|
|
result_lines.append('</blockquote>')
|
|
in_quote = False
|
|
|
|
# List items (- text)
|
|
if stripped.startswith('- '):
|
|
if not in_list:
|
|
result_lines.append('<ul class="forum-list">')
|
|
in_list = True
|
|
result_lines.append(f'<li>{stripped[2:]}</li>')
|
|
continue
|
|
elif in_list:
|
|
result_lines.append('</ul>')
|
|
in_list = False
|
|
|
|
result_lines.append(stripped)
|
|
|
|
# Close open blocks
|
|
if in_list:
|
|
result_lines.append('</ul>')
|
|
if in_quote:
|
|
result_lines.append('</blockquote>')
|
|
|
|
# Join with spaces — no extra <br> between lines within same paragraph
|
|
# Consecutive non-block lines are part of the same paragraph
|
|
output = []
|
|
for i, line in enumerate(result_lines):
|
|
s = line.strip()
|
|
# Block elements get their own line, no extra spacing
|
|
if any(s.startswith(t) for t in ['<ul', '</ul>', '<li', '</li>', '<blockquote', '</blockquote>', '<pre', '</pre>', '<br>']):
|
|
output.append(line)
|
|
else:
|
|
# Regular text — join with previous regular text using space
|
|
if output and output[-1] and not any(output[-1].strip().startswith(t) for t in ['<ul', '</ul>', '<li', '</li>', '<blockquote', '</blockquote>', '<pre', '</pre>', '<br>']):
|
|
output[-1] = output[-1] + ' ' + line
|
|
else:
|
|
output.append(line)
|
|
|
|
return Markup('\n'.join(output))
|
|
|
|
|
|
def register_markdown_filter(app):
|
|
"""Register the markdown filter with Flask app."""
|
|
app.jinja_env.filters['forum_markdown'] = parse_forum_markdown
|