""" Markdown -> HTML, for notes on the minisite. Stdlib only, and deliberately small. A notebook renders markdown itself, so notes go into it as written. The minisite has no renderer, and adding a dependency to turn a few paragraphs into HTML would break the rule that the structural path is the stdlib. So this covers what notes are actually written with: # headings paragraphs - bullet / 1. numbered lists **bold** *italic* `code` ```fenced code``` [links](url) ![images](src) | pipe | tables | > quotes --- Anything it does not recognise is **escaped, not passed through** — a note is text, and raw HTML in it is shown rather than executed. """ import re from html import escape def _url(raw: str) -> str: """An attribute-safe URL. The text around it is already escaped without quotes, so a `"` here would close the attribute; and a `javascript:` link is refused outright — a note is text, not a place to run code.""" if re.match(r"^\s*(javascript|vbscript|data):", raw, re.I): return "#" return raw.replace('"', """) INLINE = [ (re.compile(r"!\[([^\]]*)\]\(([^)\s]+)\)"), lambda m, img: img(m.group(1), m.group(2))), (re.compile(r"\[([^\]]+)\]\(([^)\s]+)\)"), lambda m, img: f'{m.group(1)}'), (re.compile(r"\*\*(.+?)\*\*"), lambda m, img: f"{m.group(1)}"), (re.compile(r"(?{m.group(1)}"), ] def _inline(text: str, image) -> str: # Code spans first and protected, so nothing inside them is formatted. spans: list[str] = [] def keep(m): spans.append(f"{escape(m.group(1))}") return f"\x00{len(spans) - 1}\x00" text = re.sub(r"`([^`]+)`", keep, text) text = escape(text, quote=False) for pattern, render in INLINE: text = pattern.sub(lambda m: render(m, image), text) return re.sub(r"\x00(\d+)\x00", lambda m: spans[int(m.group(1))], text) def to_html(text: str, *, image=None) -> str: """Render a note. `image(alt, src) -> html` lets the caller embed local images.""" image = image or (lambda alt, src: f'{alt.replace(chr(34), ') lines = text.replace("\r\n", "\n").split("\n") out: list[str] = [] i = 0 def para(buf): if buf: out.append(f"

{_inline(' '.join(s.strip() for s in buf), image)}

") buf: list[str] = [] while i < len(lines): line = lines[i] stripped = line.strip() if stripped.startswith("```"): para(buf); buf = [] code = [] i += 1 while i < len(lines) and not lines[i].strip().startswith("```"): code.append(lines[i]) i += 1 out.append(f"
{escape(chr(10).join(code))}
") i += 1 continue if not stripped: para(buf); buf = [] i += 1 continue m = re.match(r"^(#{1,6})\s+(.*)$", stripped) if m: para(buf); buf = [] level = len(m.group(1)) out.append(f"{_inline(m.group(2), image)}") i += 1 continue if re.match(r"^(-{3,}|\*{3,})$", stripped): para(buf); buf = [] out.append("
") i += 1 continue if stripped.startswith(">"): para(buf); buf = [] quote = [] while i < len(lines) and lines[i].strip().startswith(">"): quote.append(lines[i].strip()[1:].strip()) i += 1 out.append(f"
{to_html(chr(10).join(quote), image=image)}
") continue if re.match(r"^([-*+]|\d+[.)])\s+", stripped): para(buf); buf = [] ordered = bool(re.match(r"^\d", stripped)) items = [] while i < len(lines) and re.match(r"^\s*([-*+]|\d+[.)])\s+", lines[i]): items.append(re.sub(r"^\s*([-*+]|\d+[.)])\s+", "", lines[i])) i += 1 tag = "ol" if ordered else "ul" out.append(f"<{tag}>" + "".join(f"
  • {_inline(it, image)}
  • " for it in items) + f"") continue if stripped.startswith("|") and i + 1 < len(lines) \ and re.match(r"^\|?\s*:?-{3,}", lines[i + 1].strip()): para(buf); buf = [] def cells(row): return [c.strip() for c in row.strip().strip("|").split("|")] head = cells(lines[i]) i += 2 rows = [] while i < len(lines) and lines[i].strip().startswith("|"): rows.append(cells(lines[i])) i += 1 html = "" + "".join(f"" for h in head) + "" for r in rows: html += "" + "".join(f"" for c in r) + "" out.append(html + "
    {_inline(h, image)}
    {_inline(c, image)}
    ") continue buf.append(line) i += 1 para(buf) return "\n".join(out)