Wren Linux

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