#!/usr/bin/env python # /// script # requires-python = ">=3.10" # dependencies = ["mistune>=3.0"] # /// """Convert a Markdown file into a Mews page (Mews Profile 0.1). Parsing is CommonMark (mistune) plus pipe tables; uv installs mistune from the script header on the first run. The converter maps the tree onto what mews-0.1.dtd permits: ATX and setext headings, paragraphs, emphasis, strong, code, links, images (with titles), hard breaks, fenced and indented code blocks, block quotes, ordered and unordered lists (with start), and pipe tables. Raw HTML is escaped as text and table alignment markers are dropped, because Mews has neither. Tables are recognized only at the top level, matching the DTD, which allows them nowhere else. Run with: uv run md2mews.py page.md -o page.html --lang en """ import argparse import html import sys from pathlib import Path import mistune VERSION = "0.1" DOCTYPE = ( '' ) VARIANTS = ("mews-warm", "mews-cool", "mews-green", "mews-mono") MARKDOWN = mistune.create_markdown(renderer="ast", plugins=["table"]) def esc(text): return html.escape(text) def plain(nodes): """Text content of inline nodes, used for image alt text.""" parts = [] for node in nodes: if node["type"] in ("text", "codespan", "inline_html"): parts.append(node.get("raw", "")) else: parts.append(plain(node.get("children", []))) return "".join(parts) def inline_nodes(nodes): out = [] for node in nodes: kind, attrs = node["type"], node.get("attrs", {}) if kind == "text": out.append(esc(node["raw"])) elif kind == "codespan": out.append(f"{esc(node['raw'])}") elif kind == "emphasis": out.append(f"{inline_nodes(node['children'])}") elif kind == "strong": out.append(f"{inline_nodes(node['children'])}") elif kind == "link": title = f' title="{esc(attrs["title"])}"' if attrs.get("title") else "" out.append(f'{inline_nodes(node["children"])}') elif kind == "image": title = f' title="{esc(attrs["title"])}"' if attrs.get("title") else "" alt = esc(plain(node["children"])) out.append(f'{alt}') elif kind == "linebreak": out.append("
") elif kind == "softbreak": out.append(" ") elif kind == "inline_html": out.append(esc(node["raw"])) else: sys.exit(f"error: unsupported inline markup: {kind}") return "".join(out) def render_block(node, level, no_table): """Render one block node into indented lines. no_table is set inside block quotes and list items, where the Mews DTD does not allow a table. """ pad = " " * level kind, attrs = node["type"], node.get("attrs", {}) if kind in ("paragraph", "block_text"): return [f"{pad}

{inline_nodes(node['children'])}

" if kind == "paragraph" else f"{pad}{inline_nodes(node['children'])}"] if kind == "heading": tag = f"h{attrs['level']}" return [f"{pad}<{tag}>{inline_nodes(node['children'])}"] if kind == "block_quote": return [f"{pad}
", *render_blocks(node["children"], level + 1, True), f"{pad}
"] if kind == "block_code": return [f"{pad}
{esc(node['raw'].rstrip(chr(10)))}
"] if kind == "thematic_break": return [f"{pad}
"] if kind == "block_html": return [f"{pad}

{esc(node['raw'].strip())}

"] if kind == "list": tag = "ol" if attrs["ordered"] else "ul" start = f' start="{attrs["start"]}"' if attrs.get("start") not in (None, 1) else "" out = [f"{pad}<{tag}{start}>"] for item in node["children"]: out.extend(render_item(item, level + 1)) out.append(f"{pad}") return out if kind == "table": if no_table: sys.exit("error: a table inside a quote or list item cannot be a Mews page") return render_table(node, pad) sys.exit(f"error: unsupported Markdown block: {kind}") def render_item(node, level): pad = " " * level children = node["children"] if len(children) == 1 and children[0]["type"] == "block_text": return [f"{pad}
  • {inline_nodes(children[0]['children'])}
  • "] out = [f"{pad}
  • ", *render_blocks(children, level + 1, True), f"{pad}
  • "] return out def render_blocks(nodes, level, no_table): lines = [] for node in nodes: if node["type"] == "blank_line": continue lines.extend(render_block(node, level, no_table)) return lines def render_table(node, pad): out = [f"{pad}"] for section in node["children"]: rows = [section] if section["type"] == "table_head" else section["children"] for row in rows: cells = "" for cell in row["children"]: tag = "th" if cell["attrs"].get("head") else "td" cells += f"<{tag}>{inline_nodes(cell['children'])}" out.append(f"{pad} {cells}") out.append(f"{pad}
    ") return out def page(title, body, language, direction, variant, description): attrs = f'xml:lang="{language}" lang="{language}"' if direction: attrs += f' dir="{direction}"' head = [ f" {esc(title)}", f' ', ' ', ] if description: head.append(f' ') head.append(' ') body_attrs = f' class="{variant}"' if variant else "" return "\n".join( [ '', DOCTYPE, f'', " ", *head, " ", f" ", *body, " ", "", "", ] ) def main(): parser = argparse.ArgumentParser(description="Convert a Markdown file into a Mews page.") parser.add_argument("source", help="Markdown file to convert") parser.add_argument("-o", "--output", help='output file, or "-" for stdout (default: source name with .html)') parser.add_argument("--title", help="page title (default: first top-level heading)") parser.add_argument("--description", help="meta description") parser.add_argument("--lang", default="en", help="page language (default: en)") parser.add_argument("--dir", choices=("ltr", "rtl"), help="text direction") parser.add_argument("--variant", choices=VARIANTS, help="body variant class (SPEC.md 5.3)") args = parser.parse_args() source = Path(args.source) blocks = MARKDOWN(source.read_text(encoding="utf-8-sig")) title = args.title if not title: title = next( (plain(block["children"]) for block in blocks if block["type"] == "heading" and block["attrs"]["level"] == 1), None, ) or source.stem result = page(title, render_blocks(blocks, 1, False), args.lang, args.dir, args.variant, args.description) if args.output == "-": sys.stdout.write(result) else: output = Path(args.output) if args.output else source.with_suffix(".html") output.write_text(result, encoding="utf-8") print(f"wrote {output} ({title})") if __name__ == "__main__": main()