"""把 Markdown 转成 HTML。
构建后端只依赖标准库,所以这里自带一个精简的 Markdown 渲染器,
覆盖 README 里常见与可能出现的写法:
* 标题(# ~ ######)与 setext 标题(=== / ---)
* 段落
* 无序列表(+ - *)与有序列表(1. 1)),支持嵌套与任务列表
* 围栏代码块(```)与缩进代码块(4 空格)
* 行内代码、**粗体**、*斜体*、~~删除线~~
* [文字](链接)、、引用式链接 [文字][ref] 与 [ref]: 地址
* 裸露的 URL、邮箱、 自动链接
* 引用块(>)
* 分隔线(---)
* 表格(GitHub 风格)
"""
from __future__ import annotations
import html
import re
# --------------------------------------------------------------------------
# 行内
# --------------------------------------------------------------------------
CODE_SPAN_RE = re.compile(r"`([^`]+)`")
# 链接地址里允许出现一层成对的括号,例如维基百科风格的链接
_URL = r"[^()\s]*(?:\([^()]*\)[^()\s]*)*"
IMAGE_RE = re.compile(r"!\[([^\]]*)\]\((" + _URL + r")(?:\s+\"[^\"]*\")?\)")
LINK_RE = re.compile(r"\[([^\]]*)\]\((" + _URL + r")(?:\s+\"[^\"]*\")?\)")
REF_LINK_RE = re.compile(r"\[([^\]]*)\]\[([^\]]*)\]")
AUTOLINK_RE = re.compile(r"https?://[^\s<>\"'),,。]+")
ANGLE_AUTOLINK_RE = re.compile(r"<((?:https?|mailto):[^>\s]+)>")
EMAIL_RE = re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.-]+\b")
BOLD_RE = re.compile(r"\*\*(.+?)\*\*")
ITALIC_RE = re.compile(r"(?[ \t]*)(?P[-*+]|\d+[.)])(?:[ \t]+(?P.*))?$"
)
_PLACEHOLDER = "\x00{}\x00"
def _stash(store: list[str], html_text: str) -> str:
"""把已渲染好的片段存起来,返回占位符。"""
store.append(html_text)
return _PLACEHOLDER.format(len(store) - 1)
def _restore(text: str, store: list[str]) -> str:
for index, value in enumerate(store):
text = text.replace(_PLACEHOLDER.format(index), value)
return text
def render_inline(text: str, references: dict[str, str] | None = None) -> str:
"""渲染行内标记。"""
store: list[str] = []
references = references or {}
# 行内代码:内容原样转义,不参与后续的强调处理
def code_sub(match: re.Match) -> str:
return _stash(store, f"{html.escape(match.group(1))}")
text = CODE_SPAN_RE.sub(code_sub, text)
# 图片:
def image_sub(match: re.Match) -> str:
alt = html.escape(match.group(1))
url = html.escape(match.group(2), quote=True)
return _stash(store, f'')
text = IMAGE_RE.sub(image_sub, text)
# 引用式链接 [文字][ref](ref 为空时用文字本身作 ref)
def ref_sub(match: re.Match) -> str:
label = match.group(1)
ref = (match.group(2) or label).strip().lower()
url = references.get(ref)
if not url:
return match.group(0)
return _stash(
store,
f'{html.escape(label)}',
)
text = REF_LINK_RE.sub(ref_sub, text)
# 链接:[文字](地址)
def link_sub(match: re.Match) -> str:
label = html.escape(match.group(1))
url = html.escape(match.group(2), quote=True)
return _stash(store, f'{label}')
text = LINK_RE.sub(link_sub, text)
# 自动链接
def angle_sub(match: re.Match) -> str:
url = match.group(1)
return _stash(
store,
f'{html.escape(url)}',
)
text = ANGLE_AUTOLINK_RE.sub(angle_sub, text)
# 裸露的 URL 与邮箱
def url_sub(match: re.Match) -> str:
url = match.group(0)
trailing = ""
# 句末标点不属于链接;成对括号之外的右括号也不属于
while url and url[-1] in ".,;:!?":
trailing = url[-1] + trailing
url = url[:-1]
while url.endswith(")") and url.count(")") > url.count("("):
trailing = ")" + trailing
url = url[:-1]
return _stash(
store,
f'{html.escape(url)}{trailing}',
)
text = AUTOLINK_RE.sub(url_sub, text)
def mail_sub(match: re.Match) -> str:
address = match.group(0)
return _stash(
store,
f''
f"{html.escape(address)}",
)
text = EMAIL_RE.sub(mail_sub, text)
# 剩下的普通文本转义
text = html.escape(text)
# 强调
text = STRIKE_RE.sub(r"\1", text)
text = BOLD_RE.sub(r"\1", text)
text = ITALIC_RE.sub(r"\1", text)
return _restore(text, store)
# --------------------------------------------------------------------------
# 块级
# --------------------------------------------------------------------------
def _is_table_separator(line: str) -> bool:
stripped = line.strip()
if "|" not in stripped or "-" not in stripped:
return False
return all(
part.strip().replace(":", "").replace("-", "") == ""
for part in stripped.strip("|").split("|")
)
def _split_table_row(line: str) -> list[str]:
stripped = line.strip()
if stripped.startswith("|"):
stripped = stripped[1:]
if stripped.endswith("|"):
stripped = stripped[:-1]
return [cell.strip() for cell in stripped.split("|")]
def _indent_of(line: str) -> int:
return len(line) - len(line.lstrip())
def _starts_block(lines: list[str], index: int) -> bool:
"""判断该行是否是一个新块的开头(用于终止段落)。"""
line = lines[index]
stripped = line.strip()
if not stripped:
return True
if stripped.startswith("```") or stripped.startswith("~~~"):
return True
if re.match(r"^#{1,6}\s", stripped):
return True
if stripped.startswith(">"):
return True
if re.match(r"^[-*+]\s", stripped):
return True
if re.match(r"^\d+[.)]\s", stripped):
return True
if line.startswith(" ") and line.strip():
return True
if HR_RE.match(line):
return True
if _is_setext_underline(stripped):
return True
if index + 1 < len(lines) and "|" in stripped and _is_table_separator(lines[index + 1]):
return True
return False
def _is_setext_underline(stripped: str) -> bool:
"""是否是 setext 标题的下划线(=== 或 ---)。"""
if not stripped:
return False
if set(stripped) == {"="}:
return True
# 单个 "-" 可能是空列表项,因此要求至少两个
return set(stripped) == {"-"} and len(stripped) >= 2
def _render_table(
lines: list[str], index: int, references: dict[str, str]
) -> tuple[str, int]:
"""渲染 GitHub 风格表格,返回 (html, 下一行下标)。"""
header = _split_table_row(lines[index])
index += 2 # 跳过表头与分隔行
rows: list[list[str]] = []
while index < len(lines) and "|" in lines[index] and lines[index].strip():
rows.append(_split_table_row(lines[index]))
index += 1
out = ['
']
out.append("
")
for cell in header:
out.append(f"
{render_inline(cell, references)}
")
out.append("
")
width = len(header)
for row in rows:
out.append("
")
# 补齐缺失的单元格,避免表格错位
cells = list(row[:width]) + [""] * max(0, width - len(row))
for cell in cells:
out.append(f"
{render_inline(cell, references)}
")
out.append("
")
out.append("
")
return "\n".join(out), index
def _dedent(lines: list[str]) -> list[str]:
"""去掉一组行的公共缩进。"""
indents = [_indent_of(line) for line in lines if line.strip()]
if not indents:
return list(lines)
common = min(indents)
return [line[common:] if line.strip() else "" for line in lines]
def _render_list(
lines: list[str],
index: int,
toc: list[dict],
used: set[str],
references: dict[str, str],
) -> tuple[str, int]:
"""渲染有序 / 无序列表(支持嵌套列表与任务列表),返回 (html, 下一行下标)。"""
match = LIST_ITEM_RE.match(lines[index])
if not match:
return "", index
ordered = bool(re.match(r"^\d+[.)]$", match.group("bullet")))
base_indent = _indent_of(lines[index])
items: list[list[str]] = [] # 每个列表项的内容行
current: list[str] = []
i = index
def is_same_kind(line: str) -> bool:
item = LIST_ITEM_RE.match(line)
if not item:
return False
if _indent_of(line) > base_indent:
return False
return bool(re.match(r"^\d+[.)]$", item.group("bullet"))) == ordered
while i < len(lines):
line = lines[i]
if not line.strip():
# 空行:其后若仍是本列表则继续,否则列表结束
look = i + 1
while look < len(lines) and not lines[look].strip():
look += 1
if look < len(lines):
item = LIST_ITEM_RE.match(lines[look])
if item and _indent_of(lines[look]) <= base_indent and is_same_kind(lines[look]):
current.append("")
i = look
continue
if item and _indent_of(lines[look]) > base_indent:
current.append("")
i = look
continue
break
item = LIST_ITEM_RE.match(line)
if is_same_kind(line):
if current:
items.append(current)
current = [(item.group("text") or "") if item else ""]
i += 1
continue
if HR_RE.match(line) and _indent_of(line) <= base_indent:
# 分隔线结束当前列表
break
if item and _indent_of(line) > base_indent:
# 嵌套列表:整块收进当前项
if current:
current.append(line)
i += 1
continue
if item and _indent_of(line) <= base_indent:
# 同级的另一种列表(例如无序列表之后紧跟有序列表):另起一个列表
break
if current:
current.append(line.strip()) # 懒续行
i += 1
continue
break
if current:
items.append(current)
tag = "ol" if ordered else "ul"
out = [f"<{tag}>"]
for item_lines in items:
content = _dedent(item_lines)
text = content[0] if content else ""
# 嵌套内容按它自己的缩进单独去掉一层,
# 否则更深一层的列表会被当成缩进代码块
inner = _dedent(content[1:]) if len(content) > 1 else []
checkbox = ""
task = re.match(r"^\[([ xX])\]\s+(.*)$", text)
if task:
checked = " checked" if task.group(1).lower() == "x" else ""
checkbox = f' '
text = task.group(2)
body = checkbox + render_inline(text, references)
if any(line.strip() for line in inner):
nested = _render("\n".join(inner), toc, used, references)
body += nested
out.append(f"
{body}
")
out.append(f"{tag}>")
return "\n".join(out), i
def render(markdown: str) -> str:
"""把 Markdown 全文转成 HTML。"""
html_text, _ = render_document(markdown)
return html_text
def _slug(text: str, used: set[str]) -> str:
"""由标题文字生成唯一锚点。"""
base = re.sub(r"[^\w一-鿿]+", "-", text.strip().lower()).strip("-")
if not base:
base = "section"
slug = base
counter = 2
while slug in used:
slug = f"{base}-{counter}"
counter += 1
used.add(slug)
return slug
def render_document(markdown: str) -> tuple[str, list[dict]]:
"""渲染 Markdown,同时返回目录(标题级别、标题文字、锚点)。
目录用于在宽屏上给正文旁边配一个可跳转的侧栏。
"""
toc: list[dict] = []
used: set[str] = set()
references = _collect_references(markdown)
html_text = _render(markdown, toc, used, references)
return html_text, toc
def _collect_references(markdown: str) -> dict[str, str]:
"""收集全文里的链接引用定义 [ref]: url。"""
references: dict[str, str] = {}
in_fence = False
for line in markdown.replace("\r\n", "\n").split("\n"):
stripped = line.strip()
if stripped.startswith("```") or stripped.startswith("~~~"):
in_fence = not in_fence
continue
if in_fence:
continue
match = REF_DEF_RE.match(line)
if match:
references[match.group(1).strip().lower()] = match.group(2)
return references
def _render(
markdown: str,
toc: list[dict],
used: set[str],
references: dict[str, str] | None = None,
) -> str:
lines = markdown.replace("\r\n", "\n").replace("\r", "\n").split("\n")
if references is None:
references = _collect_references(markdown)
out: list[str] = []
index = 0
total = len(lines)
while index < total:
line = lines[index]
stripped = line.strip()
if not stripped:
index += 1
continue
# 围栏代码块
fence = re.match(r"^(`{3,}|~{3,})\s*(\S*)\s*$", stripped)
if fence:
marker = fence.group(1)[0]
language = fence.group(2)
index += 1
code: list[str] = []
while index < total:
closing = lines[index].strip()
if closing.startswith(marker * 3) and set(closing) <= {marker, " "}:
index += 1
break
code.append(lines[index])
index += 1
class_attr = f' class="language-{html.escape(language)}"' if language else ""
body = html.escape("\n".join(code))
out.append(f"
{body}
")
continue
# 缩进代码块
if line.startswith(" ") and line.strip():
code = []
while index < total and (
lines[index].startswith(" ") or not lines[index].strip()
):
if not lines[index].strip() and (
index + 1 >= total or not lines[index + 1].startswith(" ")
):
break
code.append(lines[index][4:])
index += 1
out.append(
'
' + html.escape("\n".join(code)) + "
"
)
continue
# 链接引用定义:单独成行,不渲染
if REF_DEF_RE.match(line):
index += 1
continue
# ATX 标题
heading = re.match(r"^(#{1,6})\s+(.*)$", stripped)
if heading:
level = len(heading.group(1))
text = heading.group(2).rstrip("#").strip()
anchor = _slug(re.sub(r"`|\*\*|\*", "", text), used)
if level in (2, 3):
toc.append({"level": level, "text": re.sub(r"`|\*\*|\*", "", text), "id": anchor})
out.append(
f''
f"{render_inline(text, references)}"
)
index += 1
continue
# 分隔线
if HR_RE.match(line):
out.append("")
index += 1
continue
# 表格
if "|" in stripped and index + 1 < total and _is_table_separator(lines[index + 1]):
table_html, index = _render_table(lines, index, references)
out.append(table_html)
continue
# 引用块
if stripped.startswith(">"):
quoted = []
while index < total and lines[index].strip().startswith(">"):
quoted.append(lines[index].strip().lstrip(">").strip())
index += 1
out.append(
"
"
)
continue
# 列表
if LIST_ITEM_RE.match(stripped):
list_html, index = _render_list(lines, index, toc, used, references)
out.append(list_html)
continue
# 段落(若整段之后紧跟 === 或 ---,其实是 setext 标题)
parts: list[str] = []
while index < total and not _starts_block(lines, index):
parts.append(lines[index].strip())
index += 1
text = " ".join(parts)
underline = lines[index].strip() if index < total else ""
if parts and _is_setext_underline(underline):
level = 1 if set(underline) == {"="} else 2
anchor = _slug(re.sub(r"`|\*\*|\*", "", text), used)
if level in (2, 3):
toc.append({"level": level, "text": re.sub(r"`|\*\*|\*", "", text), "id": anchor})
out.append(
f''
f"{render_inline(text, references)}"
)
index += 1
continue
out.append("