Files
soleprint/soleprint/atlas2/docgen/emitters/markdown.py

146 lines
5.2 KiB
Python

"""
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'<a href="{_url(m.group(2))}">{m.group(1)}</a>'),
(re.compile(r"\*\*(.+?)\*\*"), lambda m, img: f"<strong>{m.group(1)}</strong>"),
(re.compile(r"(?<![*\w])\*(?!\s)(.+?)(?<!\s)\*(?![*\w])"), lambda m, img: f"<em>{m.group(1)}</em>"),
]
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"<code>{escape(m.group(1))}</code>")
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'<img src="{_url(src)}" alt="{alt.replace(chr(34), "&quot;")}">')
lines = text.replace("\r\n", "\n").split("\n")
out: list[str] = []
i = 0
def para(buf):
if buf:
out.append(f"<p>{_inline(' '.join(s.strip() for s in buf), image)}</p>")
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"<pre><code>{escape(chr(10).join(code))}</code></pre>")
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"<h{level}>{_inline(m.group(2), image)}</h{level}>")
i += 1
continue
if re.match(r"^(-{3,}|\*{3,})$", stripped):
para(buf); buf = []
out.append("<hr>")
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"<blockquote>{to_html(chr(10).join(quote), image=image)}</blockquote>")
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"<li>{_inline(it, image)}</li>" for it in items)
+ f"</{tag}>")
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 = "<table><tr>" + "".join(f"<th>{_inline(h, image)}</th>" for h in head) + "</tr>"
for r in rows:
html += "<tr>" + "".join(f"<td>{_inline(c, image)}</td>" for c in r) + "</tr>"
out.append(html + "</table>")
continue
buf.append(line)
i += 1
para(buf)
return "\n".join(out)