"""把 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) # 图片:![alt](url) 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'{alt}') 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"") 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"") out.append("") out.append("
{render_inline(cell, references)}
{render_inline(cell, references)}
") 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"") 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( "
    " + _render("\n".join(quoted), toc, used, references) + "
    " ) 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("

    " + render_inline(text, references) + "

    ") return "\n".join(out)