feat: md to mews converter scrript
This commit is contained in:
parent
63bbf4d496
commit
6dd0f7fe9a
1 changed files with 211 additions and 0 deletions
211
md2mews.py
Normal file
211
md2mews.py
Normal file
|
|
@ -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 = (
|
||||
'<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.2//EN"\n'
|
||||
' "http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd">'
|
||||
)
|
||||
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"<code>{esc(node['raw'])}</code>")
|
||||
elif kind == "emphasis":
|
||||
out.append(f"<em>{inline_nodes(node['children'])}</em>")
|
||||
elif kind == "strong":
|
||||
out.append(f"<strong>{inline_nodes(node['children'])}</strong>")
|
||||
elif kind == "link":
|
||||
title = f' title="{esc(attrs["title"])}"' if attrs.get("title") else ""
|
||||
out.append(f'<a href="{esc(attrs["url"])}"{title}>{inline_nodes(node["children"])}</a>')
|
||||
elif kind == "image":
|
||||
title = f' title="{esc(attrs["title"])}"' if attrs.get("title") else ""
|
||||
alt = esc(plain(node["children"]))
|
||||
out.append(f'<img src="{esc(attrs["url"])}" alt="{alt}"{title} />')
|
||||
elif kind == "linebreak":
|
||||
out.append("<br />")
|
||||
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}<p>{inline_nodes(node['children'])}</p>" 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}<blockquote>", *render_blocks(node["children"], level + 1, True), f"{pad}</blockquote>"]
|
||||
if kind == "block_code":
|
||||
return [f"{pad}<pre>{esc(node['raw'].rstrip(chr(10)))}</pre>"]
|
||||
if kind == "thematic_break":
|
||||
return [f"{pad}<hr />"]
|
||||
if kind == "block_html":
|
||||
return [f"{pad}<p>{esc(node['raw'].strip())}</p>"]
|
||||
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}<li>{inline_nodes(children[0]['children'])}</li>"]
|
||||
out = [f"{pad}<li>", *render_blocks(children, level + 1, True), f"{pad}</li>"]
|
||||
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}<table>"]
|
||||
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'])}</{tag}>"
|
||||
out.append(f"{pad} <tr>{cells}</tr>")
|
||||
out.append(f"{pad}</table>")
|
||||
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" <title>{esc(title)}</title>",
|
||||
f' <meta name="mews-profile" content="{VERSION}" />',
|
||||
' <meta name="viewport" content="width=device-width" />',
|
||||
]
|
||||
if description:
|
||||
head.append(f' <meta name="description" content="{esc(description)}" />')
|
||||
head.append(' <link rel="stylesheet" type="text/css" href="mews-0.1.css" />')
|
||||
body_attrs = f' class="{variant}"' if variant else ""
|
||||
return "\n".join(
|
||||
[
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
DOCTYPE,
|
||||
f'<html xmlns="http://www.w3.org/1999/xhtml" {attrs}>',
|
||||
" <head>",
|
||||
*head,
|
||||
" </head>",
|
||||
f" <body{body_attrs}>",
|
||||
*body,
|
||||
" </body>",
|
||||
"</html>",
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue