# /// zerodep # version = "0.4.1" # deps = [] # tier = "medium" # category = "text" # note = "Install/update via `zerodep add markdown`" # /// """Markdown to HTML renderer — zero dependencies, stdlib only, Python 3.10+. Part of zerodep: https://github.com/Oaklight/zerodep Copyright (c) 2026 Peng Ding. MIT License. Drop-in replacement for mistune's ``mistune.html()`` for common Markdown. Supports: - ATX and Setext headings - Paragraphs, thematic breaks, hard line breaks - Emphasis (bold, italic, bold-italic) - Inline code and fenced/indented code blocks - Links (inline, reference, autolink) and images - Ordered and unordered lists with nesting - Block quotes with nesting - GFM tables with column alignment - GFM strikethrough (~~text~~) - GFM task lists (- [ ] / - [x]) - GFM extended autolinks (bare URLs) - Backslash escapes Does NOT implement: - Raw HTML passthrough (escaped for safety) - Footnotes, definition lists, math/LaTeX Example:: from markdown import render render("# Hello\\n\\nThis is **bold**.") # '
This is bold.
\\n' """ from __future__ import annotations import html import re __all__ = [ "render", ] # ── Constants ───────────────────────────────────────────────────────────────── _ESCAPED_CHARS = r"\\!\"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~" _HARMFUL_PROTOCOLS = ("javascript:", "vbscript:", "file:", "data:") _SAFE_DATA_PREFIXES = ( "data:image/gif;", "data:image/png;", "data:image/jpeg;", "data:image/webp;", ) # ── Block-level patterns ───────────────────────────────────────────────────── _ATX_HEADING_RE = re.compile(r"^(#{1,6})(?:\s+|$)(.*?)(?:\s+#+\s*)?$") _SETEXT_HEADING_RE = re.compile(r"^(=+|-+)\s*$") _FENCED_CODE_RE = re.compile(r"^( {0,3})(`{3,}|~{3,})\s*(\S*)\s*$") _THEMATIC_BREAK_RE = re.compile( r"^ {0,3}(?:(?:-[ \t]*){3,}|(?:\*[ \t]*){3,}|(?:_[ \t]*){3,})\s*$" ) _BLOCK_QUOTE_RE = re.compile(r"^ {0,3}>[ \t]?(.*)") _UL_ITEM_RE = re.compile(r"^( *)([-*+]) (.*)") _OL_ITEM_RE = re.compile(r"^( *)(\d{1,9})([.)]) (.*)") _INDENT_CODE_RE = re.compile(r"^(?: {4}|\t)(.*)") _REF_LINK_RE = re.compile( r"""^ {0,3}\[([^\]]+)\]:\s+(\S+?)>?(?:\s+["'(](.+?)["')])?\s*$""" ) _TABLE_DELIM_RE = re.compile( r"^ {0,3}\|?[ \t]*:?-+:?[ \t]*(?:\|[ \t]*:?-+:?[ \t]*)*\|?\s*$" ) _BLANK_RE = re.compile(r"^\s*$") # ── Inline patterns ────────────────────────────────────────────────────────── _BACKSLASH_ESCAPE_RE = re.compile(r"\\([" + _ESCAPED_CHARS + r"])") _CODE_SPAN_RE = re.compile(r"(?\s]*)>") _AUTOEMAIL_RE = re.compile( r"<([a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" r"(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" r"(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)>" ) _IMAGE_RE = re.compile( r"!\[([^\[\]]*(?:\[[^\[\]]*\][^\[\]]*)*)\]" r"""\(\s*(\S+?)(?:\s+["'](.+?)["'])?\s*\)""" ) _LINK_RE = re.compile( r"\[([^\[\]]*(?:\[[^\[\]]*\][^\[\]]*)*)\]" r"""\(\s*(\S+?)(?:\s+["'](.+?)["'])?\s*\)""" ) _REF_LINK_USE_RE = re.compile(r"\[([^\[\]]+)\]\[([^\[\]]*)\]") _REF_LINK_SHORT_RE = re.compile(r"\[([^\[\]]+)\](?!\()") _STRONG_EM_RE = re.compile(r"\*{3}(.+?)\*{3}|_{3}(.+?)_{3}") _STRONG_RE = re.compile(r"\*{2}(.+?)\*{2}|_{2}(.+?)_{2}") _EM_RE = re.compile(r"(?\[\]]*[^\s<>\[\].,;:!?'\")}\]])") _HARD_BREAK_RE = re.compile(r"(?:\\| {2,})\n") # Task list marker: [ ], [x], or [X] at start of list item content _TASK_LIST_RE = re.compile(r"^\[([ xX])\]\s+") # ── Placeholder system ─────────────────────────────────────────────────────── _PH_PREFIX = "\x00PH" _PH_SUFFIX = "\x00" class _Placeholders: """Manages placeholder substitution to protect parsed regions.""" __slots__ = ("_store", "_counter") def __init__(self) -> None: self._store: list[str] = [] self._counter = 0 def put(self, value: str) -> str: idx = self._counter self._counter += 1 self._store.append(value) return f"{_PH_PREFIX}{idx}{_PH_SUFFIX}" def restore(self, text: str) -> str: for i in range(len(self._store) - 1, -1, -1): text = text.replace(f"{_PH_PREFIX}{i}{_PH_SUFFIX}", self._store[i]) return text # ── URL safety ──────────────────────────────────────────────────────────────── def _safe_url(url: str) -> str: """Sanitize a URL, blocking dangerous protocols.""" lower = url.strip().lower() if lower.startswith(_HARMFUL_PROTOCOLS) and not lower.startswith( _SAFE_DATA_PREFIXES ): return "#harmful-link" return html.escape(url, quote=True) # ── Inline helper functions ────────────────────────────────────────────────── def _apply_bare_urls(text: str, ph: _Placeholders) -> str: """Replace bare http/https URLs with link placeholders.""" def _bare_url_repl(m: re.Match[str]) -> str: url = m.group(1) return ph.put('' + html.escape(url) + "") return _BARE_URL_RE.sub(_bare_url_repl, text) def _apply_emphasis( text: str, ph: _Placeholders, ref_links: dict[str, tuple[str, str | None]], ) -> str: """Apply bold-italic, bold, italic, and strikethrough replacements.""" # Bold-italic ***text*** def _strong_em_repl(m: re.Match[str]) -> str: content = m.group(1) or m.group(2) return ph.put( "" + _parse_inline(content, ref_links) + "" ) text = _STRONG_EM_RE.sub(_strong_em_repl, text) # Bold **text** def _strong_repl(m: re.Match[str]) -> str: content = m.group(1) or m.group(2) return ph.put("" + _parse_inline(content, ref_links) + "") text = _STRONG_RE.sub(_strong_repl, text) # Italic *text* def _em_repl(m: re.Match[str]) -> str: content = m.group(1) or m.group(2) return ph.put("" + _parse_inline(content, ref_links) + "") text = _EM_RE.sub(_em_repl, text) # Strikethrough ~~text~~ def _strike_repl(m: re.Match[str]) -> str: content = m.group(1) return ph.put("" + html.escape(code, quote=True) + "")
text = _CODE_SPAN_RE.sub(_code_repl, text)
# 3. Autolinks
def _autolink_repl(m: re.Match[str]) -> str:
url = m.group(1)
return ph.put('' + html.escape(url) + "")
text = _AUTOLINK_RE.sub(_autolink_repl, text)
# 3b. Auto emails
def _autoemail_repl(m: re.Match[str]) -> str:
email = m.group(1)
return ph.put(
'' + html.escape(email) + ""
)
text = _AUTOEMAIL_RE.sub(_autoemail_repl, text)
# 4. Images
def _image_repl(m: re.Match[str]) -> str:
alt = html.escape(m.group(1), quote=True)
url = _safe_url(m.group(2))
title = m.group(3)
s = '| {_parse_inline(h, ref_links)} | \n") out.append("
|---|
| {_parse_inline(cell, ref_links)} | \n") out.append("
{escaped_code}\n"
return h, j, False
def _strip_fence_indent(line: str, indent: int) -> str:
"""Remove up to *indent* leading spaces from *line*."""
if indent <= 0:
return line
for _ in range(indent):
if line.startswith(" "):
line = line[1:]
else:
break
return line
def _try_parse_indent_code(
lines: list[str],
idx: int,
ref_links: dict[str, tuple[str, str | None]],
para_lines: list[str],
) -> _BlockResult | None:
"""Try to parse an indented code block (4-space indent)."""
if para_lines:
return None
if not _INDENT_CODE_RE.match(lines[idx]):
return None
code_lines: list[str] = []
j = idx
while j < len(lines):
ic_m = _INDENT_CODE_RE.match(lines[j])
if ic_m:
code_lines.append(ic_m.group(1))
j += 1
elif _BLANK_RE.match(lines[j]):
if j + 1 < len(lines) and _INDENT_CODE_RE.match(lines[j + 1]):
code_lines.append("")
j += 1
else:
break
else:
break
# Remove trailing blank lines
while code_lines and code_lines[-1] == "":
code_lines.pop()
escaped_code = html.escape("\n".join(code_lines), quote=False)
h = f"{escaped_code}\n"
return h, j, False
def _try_parse_blockquote(
lines: list[str],
idx: int,
ref_links: dict[str, tuple[str, str | None]],
para_lines: list[str],
) -> _BlockResult | None:
"""Try to parse a block quote (can interrupt a paragraph)."""
if not _BLOCK_QUOTE_RE.match(lines[idx]):
return None
bq_lines: list[str] = []
j = idx
while j < len(lines):
bq_match = _BLOCK_QUOTE_RE.match(lines[j])
if bq_match:
bq_lines.append(bq_match.group(1))
j += 1
elif _BLANK_RE.match(lines[j]):
if j + 1 < len(lines) and _BLOCK_QUOTE_RE.match(lines[j + 1]):
bq_lines.append("")
j += 1
else:
break
elif lines[j].strip():
# Lazy continuation
bq_lines.append(lines[j])
j += 1
else:
break
inner = _parse_blocks(bq_lines, ref_links)
h = "\n" + inner + "\n" return h, j, False def _try_parse_list( lines: list[str], idx: int, ref_links: dict[str, tuple[str, str | None]], para_lines: list[str], ) -> _BlockResult | None: """Try to parse an ordered or unordered list.""" if para_lines: return None if not (_UL_ITEM_RE.match(lines[idx]) or _OL_ITEM_RE.match(lines[idx])): return None list_html, new_idx = _parse_list_block(lines, idx, ref_links) return list_html, new_idx, False def _try_parse_table( lines: list[str], idx: int, ref_links: dict[str, tuple[str, str | None]], para_lines: list[str], ) -> _BlockResult | None: """Try to parse a GFM table.""" if para_lines: return None line = lines[idx] if "|" not in line or idx + 1 >= len(lines): return None if not _TABLE_DELIM_RE.match(lines[idx + 1]): return None header_line = line align_line = lines[idx + 1] body: list[str] = [] j = idx + 2 while j < len(lines): tl = lines[j] if "|" in tl and not _BLANK_RE.match(tl): body.append(tl) j += 1 else: break h = _render_table(header_line, align_line, body, ref_links) return h, j, False # Ordered list of try-parse functions used by the block dispatcher. _BLOCK_PARSERS = ( _try_parse_hr, _try_parse_atx_heading, _try_parse_setext_heading, _try_parse_code_fence, _try_parse_indent_code, _try_parse_blockquote, _try_parse_list, _try_parse_table, ) # ── Block parser ────────────────────────────────────────────────────────────── def _collect_ref_links( lines: list[str], ) -> dict[str, tuple[str, str | None]]: """First pass: collect reference link definitions.""" refs: dict[str, tuple[str, str | None]] = {} for line in lines: m = _REF_LINK_RE.match(line) if m: key = m.group(1).strip().lower() url = m.group(2) title = m.group(3) refs[key] = (url, title) return refs def _parse_blocks( lines: list[str], ref_links: dict[str, tuple[str, str | None]], ) -> str: """Parse block-level Markdown elements from a list of lines.""" out: list[str] = [] idx = 0 para_lines: list[str] = [] def flush_paragraph() -> None: if para_lines: text = "\n".join(para_lines) out.append("
" + _parse_inline(text.strip(), ref_links) + "
\n") para_lines.clear() while idx < len(lines): line = lines[idx] # Skip reference link definitions (already collected) if _REF_LINK_RE.match(line): flush_paragraph() idx += 1 continue # Blank line if _BLANK_RE.match(line): flush_paragraph() idx += 1 continue # Try each block-level parser in priority order result = _dispatch_block(lines, idx, ref_links, para_lines) if result is not None: block_html, idx, consumed_para = result if consumed_para: para_lines.clear() else: flush_paragraph() out.append(block_html) continue # Paragraph accumulation para_lines.append(line) idx += 1 flush_paragraph() return "".join(out) def _dispatch_block( lines: list[str], idx: int, ref_links: dict[str, tuple[str, str | None]], para_lines: list[str], ) -> _BlockResult | None: """Try each registered block parser; return first match or None.""" for try_fn in _BLOCK_PARSERS: result = try_fn(lines, idx, ref_links, para_lines) if result is not None: return result return None # ── Public API ──────────────────────────────────────────────────────────────── def render(text: str) -> str: """Convert Markdown text to HTML. Args: text: Markdown source string. Returns: HTML string. """ # Normalize line endings text = text.replace("\r\n", "\n").replace("\r", "\n") # Split into lines lines = text.split("\n") # Remove trailing newline that produces empty last element if lines and lines[-1] == "": lines.pop() # First pass: collect reference link definitions ref_links = _collect_ref_links(lines) # Second pass: parse blocks return _parse_blocks(lines, ref_links)