wren-linux / tools / site_md.py
# tools/site_md.py: turns a Markdown file of Wren Linux's (README.md, docs/) into a page of
# its website, for tools/site.py. Only what the docs use: headings, paragraphs, lists (nested,
# numbered, with [ ] boxes), fenced code, tables, rules, links, `code`, **bold** and *italic*.
#
# It is safe by building, not by cleaning: every character of the file is escaped
# (html.escape) before it reaches the page, and the only markup on it is what this file
# writes, from a short list of tags with no attributes but a heading's id, a list's start
# number and a link's href. Raw HTML in a doc (<script>, <img onerror=...>) is shown as
# text. A link's address is taken only if it is https:// or http:// with nothing but the
# characters an address has, a #name on the page, or a file of the commit, which the
# caller turns into its page on the site (resolve); anything else (javascript:, data:, a
# path out of the commit, a file that isn't there) leaves the link's text as plain text.
import html
import re
import urllib.parse
e = html.escape
URL = re.compile(r"https?://[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+")
ANCHOR = re.compile(r"[A-Za-z0-9_-]+")
FENCE = re.compile(r"^( *)(`{3,}|~{3,})(.*)$")
HEADING = re.compile(r"^(#{1,6})[ \t]+(.*?)(?:[ \t]+#+)?[ \t]*$")
RULE = re.compile(r"^ {0,3}([-*_])( *\1){2,} *$")
ITEM = re.compile(r"^( *)([-*+]|\d{1,9}[.)])( +)(.*)$")
TABLE_SEP = re.compile(r"^ *\|?( *:?-+:? *\|)+( *:?-+:? *)?\|? *$")
def slug(text):
"""A heading's id, as GitHub makes it: small letters, digits, - and _."""
s = re.sub(r"[^\w\- ]", "", text.lower()).strip().replace(" ", "-")
return re.sub(r"[^a-z0-9_-]", "", s) or "section"
class Doc:
def __init__(self, resolve):
self.resolve = resolve # (path, anchor) -> the href of that file's page, or None
self.ids = {}
self.warnings = []
self.title = None
# ---- inline: `code`, links, **bold**, *italic*; everything else escaped ----
def inline(self, s, links=True, frags=None):
# (a link's text is done with the same list of pieces as what is around it: its
# `code` may already be one of them; only the outermost call puts them back)
top = frags is None
frags = [] if top else frags
def keep(markup):
frags.append(markup)
return "\0%d\0" % (len(frags) - 1)
# \* \_ \` \[ \] \| \\ \# \< \>: the character itself
s = re.sub(r"\\([*_`\[\]|\\#<>()!-])", lambda m: keep(e(m.group(1))), s)
# `code`, ``code with ` in it``
s = re.sub(r"(`+)(.+?)(?<!`)\1(?!`)", lambda m: keep("<code>%s</code>" % e(m.group(2).strip() or m.group(2))), s)
if links:
s = re.sub(r"<(https?://[^>\s]+)>", lambda m: keep(self.link(m.group(1), e(m.group(1)))), s)
s = re.sub(r"\[((?:[^\[\]\0]|\0\d+\0)*)\]\(([^()\s]*(?:\([^()\s]*\)[^()\s]*)*)(?:\s+\"[^\"]*\")?\)",
lambda m: keep(self.link(m.group(2), self.inline(m.group(1), links=False, frags=frags))), s)
# (quotes too: nothing in the text can even look like an attribute)
s = e(s)
s = re.sub(r"\*\*(?=\S)(.+?)(?<=\S)\*\*", r"<strong>\1</strong>", s)
s = re.sub(r"(?<![\w*])\*(?=[^\s*])(.+?)(?<=[^\s*])\*(?![\w*])", r"<em>\1</em>", s)
s = re.sub(r"(?<![\w_])_(?=[^\s_])(.+?)(?<=[^\s_])_(?![\w_])", r"<em>\1</em>", s)
while top and "\0" in s:
s = re.sub(r"\0(\d+)\0", lambda m: frags[int(m.group(1))], s)
return s
def link(self, url, text):
"""<a href=...>text</a> for an address this takes, otherwise the text alone."""
href = None
if URL.fullmatch(url):
href = url
elif url.startswith("#"):
href = url if ANCHOR.fullmatch(url[1:]) else None
elif not re.match(r"[A-Za-z][A-Za-z0-9+.-]*:", url) and not url.startswith("//"):
path, _, anchor = url.partition("#")
if anchor and not ANCHOR.fullmatch(anchor):
anchor = ""
href = self.resolve(urllib.parse.unquote(path), anchor)
if href is None:
self.warnings.append(url)
return text
return '<a href="%s">%s</a>' % (e(href, quote=True), text)
# ---- blocks ----
def blocks(self, lines):
out, para = [], []
def end_para():
if para:
out.append(("p", "<p>%s</p>" % self.inline("\n".join(l.strip() for l in para))))
para.clear()
i = 0
while i < len(lines):
line = lines[i]
if not line.strip():
end_para()
i += 1
continue
m = FENCE.match(line)
if m:
end_para()
indent, mark = len(m.group(1)), m.group(2)
code, i = [], i + 1
while i < len(lines) and not re.match(r"^ *%s%s* *$" % (re.escape(mark[0]), re.escape(mark)), lines[i]):
code.append(re.sub(r"^ {0,%d}" % indent, "", lines[i]))
i += 1
i += 1
out.append(("pre", "<pre><code>%s</code></pre>" % e("\n".join(code))))
continue
m = HEADING.match(line)
if m:
end_para()
level = min(len(m.group(1)), 4)
text = m.group(2)
plain = re.sub(r"[`*_\\]", "", re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", text))
if self.title is None and level == 1:
self.title = plain.strip()
base = slug(plain)
n = self.ids.get(base, 0)
self.ids[base] = n + 1
hid = base if n == 0 else "%s-%d" % (base, n)
out.append(("h", '<h%d id="%s">%s</h%d>' % (level, hid, self.inline(text), level)))
i += 1
continue
if RULE.match(line):
end_para()
out.append(("hr", "<hr>"))
i += 1
continue
if line.lstrip().startswith("|") and i + 1 < len(lines) and TABLE_SEP.match(lines[i + 1]):
end_para()
rows = [line]
i += 2
while i < len(lines) and lines[i].lstrip().startswith("|"):
rows.append(lines[i])
i += 1
out.append(("table", self.table(rows)))
continue
m = ITEM.match(line)
if m and (not para or not m.group(2)[0].isdigit() or m.group(2) in ("1.", "1)")):
end_para()
html_list, i = self.list(lines, i)
out.append(("list", html_list))
continue
para.append(line)
i += 1
end_para()
return out
def cells(self, row):
row = row.strip()
row = row[1:] if row.startswith("|") else row
row = row[:-1] if row.endswith("|") and not row.endswith("\\|") else row
# a | inside `code` or written \| is the cell's own
parts, cur, tick = [], "", False
j = 0
while j < len(row):
c = row[j]
if c == "\\" and j + 1 < len(row) and row[j + 1] == "|":
cur += "\\|"
j += 2
continue
if c == "`":
tick = not tick
if c == "|" and not tick:
parts.append(cur)
cur = ""
else:
cur += c
j += 1
parts.append(cur)
return [p.strip() for p in parts]
def table(self, rows):
head = "".join("<th>%s</th>" % self.inline(c) for c in self.cells(rows[0]))
body = "\n".join("<tr>%s</tr>" % "".join("<td>%s</td>" % self.inline(c) for c in self.cells(r)) for r in rows[1:])
return '<div class="table"><table>\n<thead><tr>%s</tr></thead>\n<tbody>\n%s\n</tbody>\n</table></div>' % (head, body)
def list(self, lines, i):
"""The list that starts at lines[i]: its items, each its own blocks (a list in it too)."""
first = ITEM.match(lines[i])
indent, ordered = len(first.group(1)), first.group(2)[0].isdigit()
items, loose = [], False
while i < len(lines):
m = ITEM.match(lines[i])
if not m or len(m.group(1)) != indent or m.group(2)[0].isdigit() != ordered:
break
col = indent + len(m.group(2)) + min(len(m.group(3)), 4)
body, i = [m.group(4)], i + 1
while i < len(lines):
l = lines[i]
if not l.strip():
# a blank line: the item goes on only if what follows is indented into it
nxt = next((x for x in lines[i + 1:] if x.strip()), None)
if nxt is None or len(nxt) - len(nxt.lstrip()) < col:
break
body.append("")
i += 1
continue
lead = len(l) - len(l.lstrip())
if lead >= col:
body.append(l[col:])
elif lead > indent and not ITEM.match(l):
body.append(l.strip()) # a wrapped line, indented less than the text
elif not ITEM.match(l) and not FENCE.match(l) and not HEADING.match(l) and body[-1].strip() \
and not l.lstrip().startswith("|"):
body.append(l.strip()) # a paragraph's next line, not indented at all
else:
break
i += 1
if i < len(lines) and not lines[i].strip():
nxt = next((j for j in range(i, len(lines)) if lines[j].strip()), None)
if nxt is not None:
m2 = ITEM.match(lines[nxt])
if m2 and len(m2.group(1)) == indent and m2.group(2)[0].isdigit() == ordered:
loose = True
i = nxt
if "" in body:
loose = True
items.append((m.group(2), body))
out = []
for _, body in items:
text = body[0]
box = re.match(r"\[([ xX])\] +(.*)$", text)
if box:
body = [("\u2611 " if box.group(1) in "xX" else "\u2610 ") + box.group(2)] + body[1:]
parts = self.blocks(body)
if not loose:
parts = [(k, h[3:-4]) if k == "p" else (k, h) for k, h in parts]
out.append("<li>%s</li>" % "\n".join(h for _, h in parts))
start = int(re.match(r"\d+", items[0][0]).group()) if ordered else 1
tag = "ol" if ordered else "ul"
attr = ' start="%d"' % start if ordered and start != 1 else ""
return "<%s%s>\n%s\n</%s>" % (tag, attr, "\n".join(out), tag), i
def render(text, resolve):
"""(the page's body, its title or None, the link addresses that were left as text)"""
text = text.replace("\r\n", "\n").replace("\0", "").expandtabs(4)
d = Doc(resolve)
body = "\n".join(h for _, h in d.blocks(text.split("\n")))
return body, d.title, d.warnings