md_convert
Convert Markdown given inline to HTML (escaped: raw HTML is never passed through), to plain text, or to a slide-deck outline split on headings. Covers a CommonMark subset (headings, emphasis with the CommonMark delimiter rules, code spans and blocks, lists, links, images, autolinks, block quotes, breaks, escapes, entities) plus GFM pipe tables; no link reference definitions.
Install
/tools install md_convert /tools install md_convert@1.0.0
In a console session, or let the model find it in normal mode: axlr_catalog describes it and axlr_install_tool asks for the same install card. The card reads catalog axlr · md_convert 1.0.0 · verified once the index signature, the manifest digest and every file digest check out. Then the model runs it with axlr_run_tool md_convert; each run is approved as a local_exec.
Arguments
One JSON object on stdin, validated against the schema before the program starts.
| name | type | required | default | meaning |
|---|---|---|---|---|
markdown | string | yes | ||
to | string | no | "html" | one of "html", "text", "slides" |
split_level | integer | no | 2 | slides: headings of this level or higher start a slide. ≥ 1; ≤ 6 |
Input schema (JSON)
{
"type": "object",
"required": [
"markdown"
],
"additionalProperties": false,
"properties": {
"markdown": {
"type": "string"
},
"to": {
"type": "string",
"enum": [
"html",
"text",
"slides"
],
"default": "html"
},
"split_level": {
"type": "integer",
"minimum": 1,
"maximum": 6,
"default": 2,
"description": "slides: headings of this level or higher start a slide"
}
}
}
Example
Input
{
"markdown": "# Notes\n\nSome *emphasis* and a [link](/uri \"title\").\n"
}
Output (stdout)
{
"html": "<h1>Notes</h1>\n<p>Some <em>emphasis</em> and a <a href=\"/uri\" title=\"title\">link</a>.</p>\n"
}
Files
The command the install card registers is python3 md_convert.py, run in the workspace with PATH and HOME as its only environment. Each file's SHA-256 is in the signed index; the console checks them before showing the card and again before writing.
md_convert.py · sha256 035bb06e0e7116e3bed85c39f163f6336254fc7f5aa40dbee0ccb6a34980cc5f
#!/usr/bin/env python3
"""Markdown (a CommonMark subset plus GFM tables) to HTML, to plain text, or to a slide-deck
outline split on headings. Raw HTML in the input is escaped, never passed through.
Supported: ATX and setext headings, paragraphs, thematic breaks, indented and fenced code
blocks, block quotes, bullet and ordered lists (tight and loose), GFM pipe tables; inline:
backslash escapes, entity references, code spans, emphasis and strong emphasis (the
CommonMark delimiter algorithm), inline links and images, autolinks, hard and soft breaks.
Not supported: link reference definitions, HTML blocks and raw inline HTML (escaped instead).
Input (JSON on stdin):
markdown the text (required)
to "html" (default) | "text" | "slides"
split_level slides: headings of this level or higher start a slide (default 2)
"""
import html
import json
import re
import sys
import unicodedata
# ---------------------------------------------------------------- blocks
THEMATIC = re.compile(r"^ {0,3}(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$")
ATX = re.compile(r"^ {0,3}(#{1,6})(?:[ \t]+|$)(.*)$")
FENCE = re.compile(r"^( {0,3})(`{3,}|~{3,})(.*)$")
SETEXT = re.compile(r"^ {0,3}(=+|-+)[ \t]*$")
BULLET = re.compile(r"^( {0,3})([-+*])(?=[ \t]|$)")
ORDERED = re.compile(r"^( {0,3})(\d{1,9})([.)])(?=[ \t]|$)")
QUOTE = re.compile(r"^ {0,3}>")
TABLE_DELIM = re.compile(r"^ {0,3}\|?[ \t]*:?-+:?[ \t]*(?:\|[ \t]*:?-+:?[ \t]*)*\|?[ \t]*$")
def expand_tabs(line):
"""Expand tabs in the leading whitespace (tab stops of 4); tabs inside content stay."""
out, col, k = [], 0, 0
while k < len(line) and (line[k] in " \t" or line[k] in ">-+*.)0123456789" and "\t" in line[k:k + 12]):
if line[k] == "\t":
n = 4 - col % 4
out.append(" " * n)
col += n
else:
out.append(line[k])
col += 1
k += 1
return "".join(out) + line[k:]
class Lazy(str):
"""A lazy continuation line: it continues a paragraph and cannot be a setext underline."""
class Collector(list):
"""Lines of a container (quote, list item) that track, as they are added, whether the
content ends inside a fenced code block and where the last paragraph-like run began, so
a lazy continuation line can be judged without rescanning."""
def __init__(self, lines=()):
super().__init__()
self.fence, self.run_first, self.closed = None, None, True
for line in lines:
self.append(line)
def append(self, line):
super().append(line)
if self.fence is None:
m = FENCE.match(line)
if m and not (m.group(2)[0] == "`" and "`" in m.group(3)):
self.fence = m.group(2)
elif re.match(r"^ {0,3}" + re.escape(self.fence[0]) + "{" + str(len(self.fence)) + r",}[ \t]*$", line):
self.fence = None
if is_blank(line) or ATX.match(line) or THEMATIC.match(line):
self.run_first, self.closed = None, True
elif self.run_first is None:
self.run_first, self.closed = line, False
def extend(self, lines):
for line in lines:
self.append(line)
def __iadd__(self, lines):
self.extend(lines)
return self
def lazy_ok(lines):
"""The collected lines end in a paragraph that a lazy line may continue."""
if not lines or lines.fence is not None or lines.closed:
return False
first = lines.run_first
return not (indent_of(first) >= 4 or FENCE.match(first))
def indent_of(line):
return len(line) - len(line.lstrip(" "))
def is_blank(line):
return not line.strip()
def list_marker(line):
"""(kind, marker, content_offset, rest) of a list item start, or None."""
m = BULLET.match(line)
kind = "bullet"
if not m:
m = ORDERED.match(line)
kind = "ordered"
if not m:
return None
end = m.end()
rest = line[end:]
spaces = len(rest) - len(rest.lstrip(" "))
if is_blank(rest):
offset = end + 1
content = ""
elif spaces > 4:
offset = end + 1
content = rest[1:]
else:
offset = end + spaces
content = rest[spaces:]
marker = m.group(2) if kind == "bullet" else m.group(3)
start = int(m.group(2)) if kind == "ordered" else None
return {"kind": kind, "marker": marker, "offset": offset, "content": content, "start": start}
def split_row(line):
s = line.strip()
if s.startswith("|"):
s = s[1:]
if s.endswith("|") and not s.endswith("\\|"):
s = s[:-1]
cells, cur, i = [], [], 0
while i < len(s):
if s[i] == "\\" and i + 1 < len(s) and s[i + 1] == "|":
cur.append("|")
i += 2
continue
if s[i] == "|":
cells.append("".join(cur).strip())
cur = []
else:
cur.append(s[i])
i += 1
cells.append("".join(cur).strip())
return cells
def starts_block(line, in_paragraph=True):
if is_blank(line):
return True
if THEMATIC.match(line) or ATX.match(line) or QUOTE.match(line):
return True
m = FENCE.match(line)
if m and not (m.group(2)[0] == "`" and "`" in m.group(3)):
return True
lm = list_marker(line)
if lm and in_paragraph:
# a list item can interrupt a paragraph only if it is not empty and an ordered one starts at 1
return bool(lm["content"].strip()) and (lm["kind"] == "bullet" or lm["start"] == 1)
return bool(lm)
def parse_blocks(lines):
blocks, i, n = [], 0, len(lines)
while i < n:
line = lines[i]
if is_blank(line):
blocks.append({"type": "blank"})
i += 1
continue
if indent_of(line) >= 4:
body = []
while i < n and (indent_of(lines[i]) >= 4 or is_blank(lines[i])):
body.append(lines[i][4:] if not is_blank(lines[i]) else lines[i][4:] if len(lines[i]) > 4 else "")
i += 1
while body and is_blank(body[-1]):
body.pop()
i -= 1
blocks.append({"type": "code", "info": "", "text": "".join(b + "\n" for b in body)})
continue
m = FENCE.match(line)
if m and not (m.group(2)[0] == "`" and "`" in m.group(3)):
pad, fence, info = len(m.group(1)), m.group(2), m.group(3).strip()
body = []
i += 1
while i < n:
close = re.match(r"^ {0,3}(" + re.escape(fence[0]) + "{" + str(len(fence)) + r",})[ \t]*$", lines[i])
if close:
i += 1
break
text = lines[i]
strip = min(pad, indent_of(text))
body.append(text[strip:])
i += 1
blocks.append({"type": "code", "info": unescape_text(info), "text": "".join(b + "\n" for b in body)})
continue
if THEMATIC.match(line):
blocks.append({"type": "hr"})
i += 1
continue
m = ATX.match(line)
if m:
text = m.group(2)
text = re.sub(r"(?:^|[ \t]+)#+[ \t]*$", "", text).strip()
blocks.append({"type": "heading", "level": len(m.group(1)), "text": text})
i += 1
continue
if QUOTE.match(line):
inner = Collector()
while i < n:
cur = lines[i]
if QUOTE.match(cur):
stripped = re.sub(r"^ {0,3}> ?", "", cur, count=1)
inner.append(stripped)
i += 1
elif not is_blank(cur) and lazy_ok(inner) and not starts_block(cur) and indent_of(cur) < 4 or \
not is_blank(cur) and lazy_ok(inner) and indent_of(cur) >= 4:
inner.append(Lazy(cur)) # lazy continuation of a paragraph
i += 1
else:
break
blocks.append({"type": "quote", "children": parse_blocks(inner)})
continue
lm = list_marker(line)
if lm:
items, kind, marker, start, loose = [], lm["kind"], lm["marker"], lm["start"], False
while i < n:
lm = list_marker(lines[i])
if not lm or lm["kind"] != kind or lm["marker"] != marker or THEMATIC.match(lines[i]):
break
offset = lm["offset"]
body = Collector([lm["content"]])
i += 1
while i < n:
cur = lines[i]
if is_blank(cur):
j = i
while j < n and is_blank(lines[j]):
j += 1
if is_blank(body[0]) and len(body) == 1:
break # an item can begin with at most one blank line
if j < n and indent_of(lines[j]) >= offset:
body += [""] * (j - i)
i = j
continue
break
if indent_of(cur) >= offset:
body.append(cur[offset:])
i += 1
continue
if lazy_ok(body) and not list_marker(cur) and (indent_of(cur) >= 4 or not starts_block(cur)):
body.append(Lazy(cur)) # lazy continuation
i += 1
continue
break
children = parse_blocks(body)
seen, gap = False, False
for b in children: # a blank line between two direct children makes the list loose
if b["type"] == "blank":
gap = seen
else:
loose = loose or gap
seen, gap = True, False
items.append({"children": [b for b in children if b["type"] != "blank"]})
j = i
while j < n and is_blank(lines[j]):
j += 1
if j > i:
nxt = list_marker(lines[j]) if j < n else None
if nxt and nxt["kind"] == kind and nxt["marker"] == marker and not THEMATIC.match(lines[j]):
loose = True
i = j
continue
break
blocks.append({"type": "list", "kind": kind, "start": start, "items": items, "loose": loose})
continue
# table: a header row followed by a delimiter row with the same number of cells
if "|" in line and i + 1 < n and TABLE_DELIM.match(lines[i + 1]) and "-" in lines[i + 1]:
head, delim = split_row(line), split_row(lines[i + 1])
if len(head) == len(delim):
aligns = []
for d in delim:
d = d.strip()
aligns.append("center" if d.startswith(":") and d.endswith(":") else
"right" if d.endswith(":") else "left" if d.startswith(":") else None)
rows = []
i += 2
while i < n and not is_blank(lines[i]) and not starts_block(lines[i], False):
cells = split_row(lines[i])
cells = (cells + [""] * len(head))[:len(head)]
rows.append(cells)
i += 1
blocks.append({"type": "table", "head": head, "aligns": aligns, "rows": rows})
continue
# paragraph, possibly a setext heading
para = [line]
i += 1
heading = None
while i < n:
cur = lines[i]
st = SETEXT.match(cur) if not isinstance(cur, Lazy) else None
if st:
heading = 1 if st.group(1)[0] == "=" else 2
i += 1
break
if starts_block(cur):
break
if "|" in cur and i + 1 < n and TABLE_DELIM.match(lines[i + 1]) and "-" in lines[i + 1] \
and len(split_row(cur)) == len(split_row(lines[i + 1])):
break
para.append(cur)
i += 1
text = "\n".join(p.lstrip(" ") for p in para)
if heading:
blocks.append({"type": "heading", "level": heading, "text": text.strip()})
else:
blocks.append({"type": "paragraph", "text": text.rstrip(" ")})
return blocks
# ---------------------------------------------------------------- inlines
PUNCT_ASCII = set("!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~")
def is_punct(ch):
return bool(ch) and (ch in PUNCT_ASCII or unicodedata.category(ch)[0] in "PS")
def is_space(ch):
return not ch or ch.isspace()
class Node:
def __init__(self, kind, text="", **attrs):
self.kind, self.text, self.children = kind, text, []
self.attrs = attrs
self.prev = self.next = None
class Delim:
def __init__(self, node, char, count, can_open, can_close, image=False):
self.node, self.char, self.count, self.orig = node, char, count, count
self.can_open, self.can_close, self.image = can_open, can_close, image
self.active = True
self.prev = self.next = None
def unescape_text(text):
text = re.sub(r"\\([!\"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~])", r"\1", text)
return decode_entities(text)
ENTITY = re.compile(r"&(?:#[xX][0-9a-fA-F]{1,6}|#[0-9]{1,7}|[A-Za-z][A-Za-z0-9]{1,31});")
def decode_entities(text):
def one(m):
s = m.group(0)
if s.startswith("&#"):
code = int(s[3:-1], 16) if s[2] in "xX" else int(s[2:-1])
return "\ufffd" if code == 0 or code > 0x10FFFF or 0xD800 <= code <= 0xDFFF else chr(code)
decoded = html.unescape(s)
return decoded if decoded != s else s
return ENTITY.sub(one, text)
def parse_link_tail(s, i):
"""After ']' at s[i-1]: parse '(dest "title")'. Returns (dest, title, end) or None."""
if i >= len(s) or s[i] != "(":
return None
j = i + 1
while j < len(s) and s[j] in " \t\n":
j += 1
dest = ""
if j < len(s) and s[j] == "<":
k = j + 1
buf = []
while k < len(s) and s[k] not in "<>\n":
if s[k] == "\\" and k + 1 < len(s) and s[k + 1] in PUNCT_ASCII:
buf.append(s[k:k + 2])
k += 2
continue
buf.append(s[k])
k += 1
if k >= len(s) or s[k] != ">":
return None
dest, j = "".join(buf), k + 1
else:
depth, k, buf = 0, j, []
while k < len(s):
c = s[k]
if c == "\\" and k + 1 < len(s) and s[k + 1] in PUNCT_ASCII:
buf.append(s[k:k + 2])
k += 2
continue
if c in " \t\n" or ord(c) < 0x20:
break
if c == "(":
depth += 1
elif c == ")":
if depth == 0:
break
depth -= 1
buf.append(c)
k += 1
if depth:
return None
dest, j = "".join(buf), k
title = None
k = j
while k < len(s) and s[k] in " \t\n":
k += 1
if k < len(s) and s[k] in "\"'(" and k > j:
close = ")" if s[k] == "(" else s[k]
m = k + 1
buf = []
while m < len(s) and s[m] != close:
if s[m] == "\\" and m + 1 < len(s) and s[m + 1] in PUNCT_ASCII:
buf.append(s[m:m + 2])
m += 2
continue
if close == ")" and s[m] == "(":
return None
buf.append(s[m])
m += 1
if m >= len(s):
return None
title = "".join(buf)
k = m + 1
while k < len(s) and s[k] in " \t\n":
k += 1
if k >= len(s) or s[k] != ")":
return None
return unescape_text(dest), (unescape_text(title) if title is not None else None), k + 1
TICKS = re.compile(r"`+")
AUTOLINK = re.compile(r"<([A-Za-z][A-Za-z0-9+.\-]{1,31}:[^\s<>]*)>")
EMAIL = re.compile(r"<([a-zA-Z0-9.!#$%&'*+/=?^_`{|}~\-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?"
r"(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)>")
PLAIN_RUN = re.compile(r"[^\\`<&\n*_!\[\]]+")
def parse_inlines(s):
head = Node("root")
tail = [head]
state = {"delims": None, "brackets": []}
def append(node):
node.prev = tail[0]
tail[0].next = node
tail[0] = node
return node
def text(t):
if tail[0].kind == "text" and not getattr(tail[0], "frozen", False):
tail[0].text += t
return tail[0]
return append(Node("text", t))
def push_delim(d):
d.prev = state["delims"]
if state["delims"]:
state["delims"].next = d
state["delims"] = d
i, n = 0, len(s)
while i < n:
c = s[i]
if c == "\\":
if i + 1 < n and s[i + 1] == "\n":
append(Node("html", "<br />\n", plain="\n"))
i += 2
while i < n and s[i] == " ":
i += 1
continue
if i + 1 < n and s[i + 1] in PUNCT_ASCII:
text(s[i + 1])
i += 2
continue
text("\\")
i += 1
continue
if c == "`":
m = TICKS.match(s, i)
ticks = m.group(0)
j = i + len(ticks)
found = None
for close in TICKS.finditer(s, j):
if len(close.group(0)) == len(ticks):
found = close
break
if found:
code = s[j:found.start()].replace("\n", " ")
if len(code) > 2 and code[0] == " " and code[-1] == " " and code.strip(" "):
code = code[1:-1]
append(Node("html", "<code>" + esc(code) + "</code>", plain=code))
i = found.end()
else:
text(ticks)
i = j
continue
if c == "<":
m = AUTOLINK.match(s, i)
if m:
url = m.group(1)
append(Node("html", f'<a href="{esc(normalize_url(url))}">{esc(url)}</a>', plain=url))
i = m.end()
continue
m = EMAIL.match(s, i)
if m:
mail = m.group(1)
append(Node("html", f'<a href="mailto:{esc(normalize_url(mail))}">{esc(mail)}</a>', plain=mail))
i = m.end()
continue
text("<")
i += 1
continue
if c == "&":
m = ENTITY.match(s, i)
if m:
decoded = decode_entities(m.group(0))
node = append(Node("text", decoded))
node.frozen = True
i = m.end()
continue
text("&")
i += 1
continue
if c == "\n":
node = tail[0]
if node.kind == "text" and node.text.endswith(" "):
node.text = node.text.rstrip(" ")
append(Node("html", "<br />\n", plain="\n"))
else:
if node.kind == "text":
node.text = node.text.rstrip(" ")
append(Node("html", "\n", plain="\n"))
i += 1
while i < n and s[i] == " ":
i += 1
continue
if c in "*_":
j = i
while j < n and s[j] == c:
j += 1
before = s[i - 1] if i > 0 else ""
after = s[j] if j < n else ""
left = not is_space(after) and (not is_punct(after) or is_space(before) or is_punct(before))
right = not is_space(before) and (not is_punct(before) or is_space(after) or is_punct(after))
if c == "*":
can_open, can_close = left, right
else:
can_open = left and (not right or is_punct(before))
can_close = right and (not left or is_punct(after))
node = append(Node("text", s[i:j]))
node.frozen = True
push_delim(Delim(node, c, j - i, can_open, can_close))
i = j
continue
if c == "!" and i + 1 < n and s[i + 1] == "[":
node = append(Node("text", "!["))
node.frozen = True
state["brackets"].append({"node": node, "image": True, "active": True, "delims": state["delims"], "pos": i + 2})
i += 2
continue
if c == "[":
node = append(Node("text", "["))
node.frozen = True
state["brackets"].append({"node": node, "image": False, "active": True, "delims": state["delims"], "pos": i + 1})
i += 1
continue
if c == "]":
if not state["brackets"]:
text("]")
i += 1
continue
opener = state["brackets"][-1]
if not opener["active"]:
state["brackets"].pop()
text("]")
i += 1
continue
tail_info = parse_link_tail(s, i + 1)
if tail_info is None:
state["brackets"].pop()
text("]")
i += 1
continue
dest, title, end = tail_info
# move the nodes after the opener into the link
link = Node("image" if opener["image"] else "link", href=dest, title=title)
first = opener["node"].next
process_emphasis(state, opener["delims"])
child = first
while child is not None:
nxt = child.next
link.children.append(child)
child = nxt
opener_node = opener["node"]
link.prev = opener_node.prev
if opener_node.prev:
opener_node.prev.next = link
tail[0] = link
link.next = None
state["brackets"].pop()
if not opener["image"]:
for b in state["brackets"]:
if not b["image"]:
b["active"] = False
i = end
continue
m = PLAIN_RUN.match(s, i)
chunk = m.group(0) if m else c
text(chunk)
i += len(chunk)
process_emphasis(state, None)
out, node = [], head.next
while node is not None:
out.append(node)
node = node.next
return out
def remove_delim(state, d):
if d.prev:
d.prev.next = d.next
if d.next:
d.next.prev = d.prev
else:
state["delims"] = d.prev
def process_emphasis(state, bottom):
# find the first delimiter above bottom
d = state["delims"]
first = None
while d is not None and d is not bottom:
first = d
d = d.prev
openers_bottom = {}
closer = first
while closer is not None:
if not closer.can_close:
closer = closer.next
continue
key = (closer.char, closer.can_open, closer.orig % 3)
opener = closer.prev
found = False
limit = openers_bottom.get(key, bottom)
while opener is not None and opener is not bottom and opener is not limit:
odd = (opener.can_close or closer.can_open) and (opener.orig + closer.orig) % 3 == 0 \
and not (opener.orig % 3 == 0 and closer.orig % 3 == 0)
if opener.char == closer.char and opener.can_open and not odd:
found = True
break
opener = opener.prev
if not found:
openers_bottom[key] = closer.prev
nxt = closer.next
if not closer.can_open:
remove_delim(state, closer)
closer = nxt
continue
use = 2 if opener.count >= 2 and closer.count >= 2 else 1
opener.count -= use
closer.count -= use
opener.node.text = opener.node.text[:len(opener.node.text) - use]
closer.node.text = closer.node.text[:len(closer.node.text) - use]
emph = Node("strong" if use == 2 else "em")
child = opener.node.next
while child is not None and child is not closer.node:
nxt = child.next
emph.children.append(child)
child = nxt
opener.node.next = emph
emph.prev = opener.node
emph.next = closer.node
closer.node.prev = emph
# delimiters between opener and closer are gone
d = closer.prev
while d is not None and d is not opener:
prev = d.prev
remove_delim(state, d)
d = prev
if opener.count == 0:
unlink(opener.node)
remove_delim(state, opener)
if closer.count == 0:
nxt = closer.next
unlink(closer.node)
remove_delim(state, closer)
closer = nxt
# remove all delimiters above bottom
while state["delims"] is not None and state["delims"] is not bottom:
remove_delim(state, state["delims"])
def unlink(node):
if node.prev:
node.prev.next = node.next
if node.next:
node.next.prev = node.prev
def esc(text):
return text.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
def safe_url(url):
"""Like cmark's --safe: javascript:, vbscript:, file: and non-image data: URLs become empty."""
scheme = re.sub(r"[\s\x00-\x1f]", "", url).lower()
if scheme.startswith(("javascript:", "vbscript:", "file:")) or \
scheme.startswith("data:") and not re.match(r"data:image/(png|gif|jpeg|webp);", scheme):
return ""
return url
def normalize_url(url):
url = safe_url(url)
safe = "%;/?:@&=+$,-_.!~*'()#"
out = []
for ch in url:
if ch.isascii() and (ch.isalnum() or ch in safe):
out.append(ch)
else:
out.append("".join(f"%{b:02X}" for b in ch.encode("utf-8")))
return "".join(out)
def render_inline(nodes):
out = []
for node in nodes:
if node.kind == "text":
out.append(esc(node.text))
elif node.kind == "html":
out.append(node.text)
elif node.kind in ("em", "strong"):
tag = node.kind
out.append(f"<{tag}>{render_inline(node.children)}</{tag}>")
elif node.kind == "link":
title = f' title="{esc(node.attrs["title"])}"' if node.attrs["title"] is not None else ""
out.append(f'<a href="{esc(normalize_url(node.attrs["href"]))}"{title}>{render_inline(node.children)}</a>')
elif node.kind == "image":
title = f' title="{esc(node.attrs["title"])}"' if node.attrs["title"] is not None else ""
out.append(f'<img src="{esc(normalize_url(node.attrs["href"]))}" alt="{esc(plain_inline(node.children))}"{title} />')
return "".join(out)
def plain_inline(nodes, links=False):
out = []
for node in nodes:
if node.kind == "text":
out.append(node.text)
elif node.kind == "html":
out.append(node.attrs.get("plain", ""))
elif node.kind in ("em", "strong"):
out.append(plain_inline(node.children, links))
elif node.kind == "link":
label = plain_inline(node.children, links)
out.append(f"{label} ({node.attrs['href']})" if links and node.attrs["href"] and node.attrs["href"] != label else label)
elif node.kind == "image":
alt = plain_inline(node.children, links)
out.append(f"[image: {alt}]" if links else alt)
return "".join(out)
# ---------------------------------------------------------------- renderers
def to_html(blocks, tight=False):
out = []
for b in blocks:
t = b["type"]
if t == "blank":
continue
if t == "paragraph":
inner = render_inline(parse_inlines(b["text"]))
out.append(inner if tight else f"<p>{inner}</p>")
elif t == "heading":
out.append(f"<h{b['level']}>{render_inline(parse_inlines(b['text']))}</h{b['level']}>")
elif t == "hr":
out.append("<hr />")
elif t == "code":
info = b["info"].split()[0] if b["info"].split() else ""
cls = f' class="language-{esc(info)}"' if info else ""
out.append(f"<pre><code{cls}>{esc(b['text'])}</code></pre>")
elif t == "quote":
inner = to_html(b["children"])
out.append("<blockquote>\n" + (inner + "\n" if inner else "") + "</blockquote>")
elif t == "list":
tag = "ol" if b["kind"] == "ordered" else "ul"
start = f' start="{b["start"]}"' if b["kind"] == "ordered" and b["start"] != 1 else ""
items = []
for item in b["items"]:
inner = to_html(item["children"], tight=not b["loose"])
if not item["children"]:
items.append("<li></li>")
elif b["loose"]:
items.append(f"<li>\n{inner}\n</li>")
else:
first_par = item["children"][0]["type"] == "paragraph"
last_par = item["children"][-1]["type"] == "paragraph"
items.append("<li>" + ("" if first_par else "\n") + inner + ("" if last_par else "\n") + "</li>")
out.append(f"<{tag}{start}>\n" + "\n".join(items) + f"\n</{tag}>")
elif t == "table":
def cell(tag, text, align):
a = f' align="{align}"' if align else ""
return f"<{tag}{a}>{render_inline(parse_inlines(text))}</{tag}>"
head = "<tr>\n" + "\n".join(cell("th", h, a) for h, a in zip(b["head"], b["aligns"])) + "\n</tr>"
rows = ["<tr>\n" + "\n".join(cell("td", c, a) for c, a in zip(r, b["aligns"])) + "\n</tr>" for r in b["rows"]]
body = ("\n<tbody>\n" + "\n".join(rows) + "\n</tbody>") if rows else ""
out.append("<table>\n<thead>\n" + head + "\n</thead>" + body + "\n</table>")
return "\n".join(out)
def to_text(blocks, prefix=""):
out = []
for b in blocks:
t = b["type"]
if t == "blank":
continue
if t in ("paragraph", "heading"):
out.append(plain_inline(parse_inlines(b["text"]), links=True))
elif t == "hr":
out.append("")
elif t == "code":
out.append(b["text"].rstrip("\n"))
elif t == "quote":
inner = to_text(b["children"])
out.append("\n".join("> " + x if x else ">" for x in inner.split("\n")))
elif t == "list":
lines = []
for k, item in enumerate(b["items"]):
mark = f"{(b['start'] or 1) + k}. " if b["kind"] == "ordered" else "- "
inner = to_text(item["children"]).split("\n")
lines.append(mark + inner[0])
lines += [" " * len(mark) + x if x else "" for x in inner[1:]]
out.append("\n".join(lines))
elif t == "table":
rows = [b["head"]] + b["rows"]
out.append("\n".join("\t".join(plain_inline(parse_inlines(c), links=True) for c in r) for r in rows))
return "\n\n".join(x for x in out if x is not None)
def to_slides(blocks, level):
slides, current = [], None
def points(bs, depth=0):
found = []
for b in bs:
t = b["type"]
if t == "paragraph":
found.append(" " * depth + plain_inline(parse_inlines(b["text"])).replace("\n", " "))
elif t == "list":
for item in b["items"]:
kids = item["children"]
first = [k for k in kids if k["type"] == "paragraph"][:1]
if first:
found.append(" " * depth + plain_inline(parse_inlines(first[0]["text"])).replace("\n", " "))
found += points([k for k in kids if k not in first], depth + 1)
elif t == "quote":
found += [" " * depth + "\u201c" + p.strip() + "\u201d" for p in points(b["children"])]
elif t == "code":
found.append(" " * depth + f"[code: {b['text'].count(chr(10))} lines]")
elif t == "table":
found.append(" " * depth + f"[table: {len(b['rows'])} rows x {len(b['head'])} columns]")
elif t == "heading":
found.append(" " * depth + plain_inline(parse_inlines(b["text"])))
return found
pending = []
for b in blocks:
if b["type"] == "heading" and b["level"] <= level:
if current is None and pending:
slides.append({"title": None, "level": None, "blocks": pending})
current = {"title": plain_inline(parse_inlines(b["text"])), "level": b["level"], "blocks": []}
slides.append(current)
elif b["type"] == "hr" and current is not None:
current = {"title": None, "level": None, "blocks": []}
slides.append(current)
elif current is None:
pending.append(b)
else:
current["blocks"].append(b)
if current is None and pending:
slides.append({"title": None, "level": None, "blocks": pending})
result, lines = [], []
for k, s in enumerate(slides, 1):
pts = points(s["blocks"])
result.append({"number": k, "title": s["title"], "level": s["level"], "points": pts})
lines.append(f"{k}. {s['title'] or '(untitled)'}")
lines += [" - " + p for p in pts]
return result, "\n".join(lines) + ("\n" if lines else "")
def fail(message):
print(json.dumps({"error": message}))
sys.exit(2)
def main():
try:
request = json.load(sys.stdin)
except ValueError as error:
fail(f"stdin is not JSON: {error}")
if not isinstance(request, dict):
fail("input must be a JSON object")
markdown = request.get("markdown")
if not isinstance(markdown, str):
fail("markdown must be a string")
if len(markdown) > 1000000:
fail("markdown is longer than 1000000 characters")
target = request.get("to", "html")
if target not in ("html", "text", "slides"):
fail("to must be html, text or slides")
level = request.get("split_level", 2)
if not isinstance(level, int) or isinstance(level, bool) or not 1 <= level <= 6:
fail("split_level must be an integer from 1 to 6")
lines = [expand_tabs(x) for x in markdown.replace("\r\n", "\n").replace("\r", "\n").replace("\0", "\ufffd").split("\n")]
if lines and lines[-1] == "":
lines.pop()
try:
convert(lines, target, level)
except RecursionError:
fail("the document nests block quotes, lists or emphasis too deeply")
def convert(lines, target, level):
blocks = parse_blocks(lines)
if target == "html":
rendered = to_html(blocks)
print(json.dumps({"to": "html", "html": rendered + "\n" if rendered else ""}, ensure_ascii=False))
elif target == "text":
print(json.dumps({"to": "text", "text": to_text(blocks) + "\n"}, ensure_ascii=False))
else:
slides, outline = to_slides(blocks, level)
print(json.dumps({"to": "slides", "split_level": level, "count": len(slides), "slides": slides,
"outline": outline}, ensure_ascii=False))
if __name__ == "__main__":
main()
Where it is served
The registry serves only AXLR clients, so a browser gets 403; fetch with a User-Agent that starts with axlr:
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/md_convert/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/md_convert/1.0.0/md_convert.py