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
Plain https:// URLs are now automatically converted to clickable links. Markdown [text](url) syntax continues to work without duplication. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
134 lines
3.7 KiB
Python
134 lines
3.7 KiB
Python
"""
|
|
Simple Markdown Parser for Forum
|
|
================================
|
|
|
|
Converts basic markdown to safe HTML.
|
|
Supports: bold, italic, code, links, lists, quotes, @mentions
|
|
"""
|
|
|
|
import re
|
|
from markupsafe import Markup, escape
|
|
|
|
|
|
def parse_forum_markdown(text):
|
|
"""
|
|
Convert markdown text to safe HTML.
|
|
|
|
Supported syntax:
|
|
- **bold** or __bold__
|
|
- *italic* or _italic_
|
|
- `inline code`
|
|
- [link text](url)
|
|
- - list items
|
|
- > quotes
|
|
- @mentions (highlighted)
|
|
|
|
Args:
|
|
text: Raw markdown text
|
|
|
|
Returns:
|
|
Markup object with safe HTML
|
|
"""
|
|
if not text:
|
|
return Markup('')
|
|
|
|
# Escape HTML first for security
|
|
text = str(escape(text))
|
|
|
|
# Process line by line for block elements
|
|
lines = text.split('\n')
|
|
result_lines = []
|
|
in_list = False
|
|
in_quote = False
|
|
|
|
for line in lines:
|
|
stripped = line.strip()
|
|
|
|
# Quote blocks (> text)
|
|
if stripped.startswith('> '): # Escaped >
|
|
if not in_quote:
|
|
result_lines.append('<blockquote class="forum-quote">')
|
|
in_quote = True
|
|
result_lines.append(stripped[5:]) # Remove > prefix
|
|
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(line)
|
|
|
|
# Close open blocks
|
|
if in_list:
|
|
result_lines.append('</ul>')
|
|
if in_quote:
|
|
result_lines.append('</blockquote>')
|
|
|
|
text = '\n'.join(result_lines)
|
|
|
|
# Inline formatting (order matters!)
|
|
|
|
# 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) # Return original if not safe
|
|
|
|
text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', safe_link, text)
|
|
|
|
# Auto-link bare URLs (must come after [text](url) so already-linked URLs aren't doubled)
|
|
text = 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
|
|
)
|
|
|
|
# @mentions - highlight them
|
|
text = re.sub(
|
|
r'@([\w.\-]+)',
|
|
r'<span class="forum-mention">@\1</span>',
|
|
text
|
|
)
|
|
|
|
# Convert newlines to <br> (but not inside pre/blockquote)
|
|
# Simple approach: just convert \n to <br>
|
|
text = text.replace('\n', '<br>\n')
|
|
|
|
return Markup(text)
|
|
|
|
|
|
def register_markdown_filter(app):
|
|
"""Register the markdown filter with Flask app."""
|
|
app.jinja_env.filters['forum_markdown'] = parse_forum_markdown
|