diff --git a/md2mews.py b/md2mews.py
new file mode 100644
index 0000000..5cb869b
--- /dev/null
+++ b/md2mews.py
@@ -0,0 +1,211 @@
+#!/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'')
+ 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'])}{tag}>"] + 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}{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}{tag}>") + 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}