diff --git a/app/main.py b/app/main.py
index 6ea8008..65d539b 100644
--- a/app/main.py
+++ b/app/main.py
@@ -16,6 +16,7 @@ from typing import Optional
import docker
import requests
+import mistune
from fastapi import Depends, FastAPI, File, Form, HTTPException, Query, Request, UploadFile, status
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
@@ -299,34 +300,16 @@ def normalize_web_target(url: str) -> str:
return f"http://{raw}"
+_md = mistune.create_markdown(
+ escape=True,
+ plugins=["strikethrough", "table", "task_lists"],
+)
+
def format_service_comment(raw_text: str) -> Markup:
raw = (raw_text or "").replace("\r\n", "\n").replace("\r", "\n").strip()
if not raw:
return Markup("")
- escaped = str(escape(raw))
- # Support pasted/plain markdown-like bold fragments.
- escaped = re.sub(r"\*\*(.+?)\*\*", r"\1", escaped, flags=re.DOTALL)
- # Allow a small safe subset of pasted HTML tags.
- replacements = {
- "<b>": "",
- "</b>": "",
- "<strong>": "",
- "</strong>": "",
- "<i>": "",
- "</i>": "",
- "<em>": "",
- "</em>": "",
- "<u>": "",
- "</u>": "",
- "<br>": "
",
- "<br/>": "
",
- "<br />": "
",
- }
- for src, dst in replacements.items():
- escaped = escaped.replace(src, dst)
- escaped = escaped.replace("\n", "
")
- return Markup(escaped)
-
+ return Markup(_md(raw))
def parse_rdp_target(target: str) -> dict:
raw = (target or "").strip()
diff --git a/app/requirements.txt b/app/requirements.txt
index c7e391d..1cd98c5 100644
--- a/app/requirements.txt
+++ b/app/requirements.txt
@@ -7,3 +7,4 @@ jinja2==3.1.6
passlib[argon2]==1.7.4
docker==7.1.0
itsdangerous==2.2.0
+mistune==3.1.3
diff --git a/app/static/style.css b/app/static/style.css
index 4eecba8..1e587d0 100644
--- a/app/static/style.css
+++ b/app/static/style.css
@@ -781,3 +781,34 @@ button {
color: rgba(240, 248, 255, 0.95);
text-shadow: 0 2px 10px rgba(9, 44, 72, 0.45);
}
+
+/* Markdown inside service card comments */
+.tile-comment p { margin: 0 0 0.4em; }
+.tile-comment p:last-child { margin-bottom: 0; }
+.tile-comment ul, .tile-comment ol { margin: 0.3em 0 0.4em 1.2em; padding: 0; }
+.tile-comment li { margin-bottom: 0.15em; }
+.tile-comment h1,.tile-comment h2,.tile-comment h3,
+.tile-comment h4,.tile-comment h5,.tile-comment h6 {
+ font-size: 0.85em; font-weight: 700; margin: 0.4em 0 0.2em;
+}
+.tile-comment code {
+ font-family: monospace; font-size: 0.88em;
+ background: rgba(0,0,0,.06); border-radius: 3px; padding: 0.1em 0.3em;
+}
+.tile-comment pre {
+ background: rgba(0,0,0,.06); border-radius: 4px;
+ padding: 0.4em 0.6em; overflow-x: auto; font-size: 0.82em;
+}
+.tile-comment pre code { background: none; padding: 0; }
+.tile-comment blockquote {
+ border-left: 3px solid #c7d9e8; margin: 0.3em 0 0.3em 0;
+ padding-left: 0.6em; color: #5a7a90;
+}
+.tile-comment a { color: #1668a6; text-decoration: underline; }
+.tile-comment table { border-collapse: collapse; font-size: 0.82em; margin: 0.3em 0; }
+.tile-comment th, .tile-comment td {
+ border: 1px solid #c7d9e8; padding: 0.2em 0.5em;
+}
+.tile-comment th { background: #eaf2fb; font-weight: 700; }
+.tile-comment del { text-decoration: line-through; color: #7a9aaf; }
+.tile-comment input[type=checkbox] { margin-right: 0.3em; }