146 lines
5.2 KiB
Python
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)  | 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), """)}">')
|
|
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)
|