"""Convert a Markdown post to WordPress Gutenberg block markup.
Why not just paste the Markdown? The block editor's Markdown handling is partial:
fenced code blocks lose their formatting, tables usually arrive as paragraphs, and
inline backticks become literal backticks. Gutenberg block markup -- HTML with
`<!-- wp:... -->` comments -- pastes into the editor's **Code editor** view and
becomes real, editable native blocks.
Usage:
python scripts/md_to_wordpress.py BLOGPOST.md
python scripts/md_to_wordpress.py BLOGPOST.md --out post.html --lang python
Handles the subset this post uses: headings, paragraphs, fenced code, tables,
ordered/unordered lists, blockquotes, horizontal rules, and inline
bold/italic/code/links. It deliberately does not try to be a general Markdown
implementation -- it tries to be correct on the input it is given.
"""
from __future__ import annotations
import argparse
import collections
import html
import json
import re
import sys
from pathlib import Path
# Fences whose language hint maps to something a highlighter plugin understands.
LANG_ALIASES = {"py": "python", "sh": "bash", "shell": "bash", "yml": "yaml", "": ""}
# --------------------------------------------------------------------- inline
def inline(text: str) -> str:
"""Markdown inline formatting -> HTML, with code spans protected."""
# Pull code spans out first so their contents are never treated as markup.
spans: list[str] = []
def stash(match: re.Match) -> str:
spans.append(html.escape(match.group(1), quote=False))
return f"\x00{len(spans) - 1}\x00"
text = re.sub(r"`([^`]+)`", stash, text)
# Escape everything else, THEN add the tags we generate ourselves.
text = html.escape(text, quote=False)
text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r'<a href="\2">\1</a>', text)
text = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", text)
# Single asterisks only when they are not part of a ** pair.
text = re.sub(r"(?<!\*)\*([^*\n]+)\*(?!\*)", r"<em>\1</em>", text)
for index, code in enumerate(spans):
text = text.replace(f"\x00{index}\x00", f"<code>{code}</code>")
return text
# --------------------------------------------------------------------- blocks
def heading_block(level: int, text: str) -> str:
attrs = "" if level == 2 else f' {{"level":{level}}}'
return (
f"<!-- wp:heading{attrs} -->\n"
f'<h{level} class="wp-block-heading">{inline(text)}</h{level}>\n'
f"<!-- /wp:heading -->"
)
def paragraph_block(text: str) -> str:
return f"<!-- wp:paragraph -->\n<p>{inline(text)}</p>\n<!-- /wp:paragraph -->"
def code_block(lines: list[str], language: str) -> str:
body = html.escape("\n".join(lines), quote=False)
# WordPress core has no syntax highlighting; the language lands in a class
# that plugins (Code Syntax Block, Enlighter, Prism) pick up, and is inert
# otherwise.
css = f' class="language-{language}"' if language else ""
return (
"<!-- wp:code -->\n"
f'<pre class="wp-block-code"><code{css}>{body}</code></pre>\n'
"<!-- /wp:code -->"
)
def quote_block(lines: list[str]) -> str:
inner = "\n".join(paragraph_block(line) for line in lines if line.strip())
return f'<!-- wp:quote -->\n<blockquote class="wp-block-quote">{inner}</blockquote>\n<!-- /wp:quote -->'
def list_block(items: list[str], ordered: bool) -> str:
tag = "ol" if ordered else "ul"
attrs = ' {"ordered":true}' if ordered else ""
# Modern Gutenberg wants each item as its own list-item block.
inner = "\n".join(
f"<!-- wp:list-item -->\n<li>{inline(item)}</li>\n<!-- /wp:list-item -->"
for item in items
)
return (
f"<!-- wp:list{attrs} -->\n"
f'<{tag} class="wp-block-list">\n{inner}\n</{tag}>\n'
f"<!-- /wp:list -->"
)
def table_block(rows: list[list[str]]) -> str:
header, *body = rows
head_html = "".join(f"<th>{inline(cell)}</th>" for cell in header)
body_html = "".join(
"<tr>" + "".join(f"<td>{inline(cell)}</td>" for cell in row) + "</tr>"
for row in body
)
return (
"<!-- wp:table -->\n"
'<figure class="wp-block-table"><table>'
f"<thead><tr>{head_html}</tr></thead>"
f"<tbody>{body_html}</tbody>"
"</table></figure>\n"
"<!-- /wp:table -->"
)
def separator_block() -> str:
return (
'<!-- wp:separator -->\n<hr class="wp-block-separator has-alpha-channel-opacity"/>\n'
"<!-- /wp:separator -->"
)
# ---------------------------------------------------------------------- parse
def split_table_row(line: str) -> list[str]:
return [cell.strip() for cell in line.strip().strip("|").split("|")]
def convert(markdown: str, default_lang: str = "") -> tuple[str, str, str]:
"""Return (title, subtitle, gutenberg_html)."""
lines = markdown.splitlines()
blocks: list[str] = []
title = ""
subtitle = ""
index = 0
while index < len(lines):
line = lines[index]
stripped = line.strip()
# blank
if not stripped:
index += 1
continue
# fenced code
if stripped.startswith("```"):
language = LANG_ALIASES.get(stripped[3:].strip(), stripped[3:].strip())
index += 1
body: list[str] = []
while index < len(lines) and not lines[index].strip().startswith("```"):
body.append(lines[index])
index += 1
index += 1 # closing fence
blocks.append(code_block(body, language or default_lang))
continue
# horizontal rule
if re.fullmatch(r"-{3,}|\*{3,}|_{3,}", stripped):
blocks.append(separator_block())
index += 1
continue
# heading
match = re.match(r"^(#{1,6})\s+(.*)$", stripped)
if match:
level, text = len(match.group(1)), match.group(2)
if level == 1 and not title:
# The H1 becomes the WordPress post title, not body content.
title = text
index += 1
# An italic line right after the title is the standfirst/excerpt.
while index < len(lines) and not lines[index].strip():
index += 1
if index < len(lines):
following = lines[index].strip()
if following.startswith("*") and following.endswith("*"):
subtitle = following.strip("*").strip()
blocks.append(
"<!-- wp:paragraph {\"className\":\"is-style-lead\"} -->\n"
f"<p class=\"is-style-lead\"><em>{inline(subtitle)}</em></p>\n"
"<!-- /wp:paragraph -->"
)
index += 1
continue
blocks.append(heading_block(min(level, 6), text))
index += 1
continue
# table
if stripped.startswith("|") and index + 1 < len(lines) and re.fullmatch(
r"\|[\s:|-]+\|", lines[index + 1].strip()
):
rows = [split_table_row(stripped)]
index += 2 # header + separator
while index < len(lines) and lines[index].strip().startswith("|"):
rows.append(split_table_row(lines[index]))
index += 1
blocks.append(table_block(rows))
continue
# blockquote
if stripped.startswith(">"):
quoted: list[str] = []
while index < len(lines) and lines[index].strip().startswith(">"):
quoted.append(lines[index].strip().lstrip(">").strip())
index += 1
blocks.append(quote_block(quoted))
continue
# list (ordered or unordered), with indented continuation lines
list_match = re.match(r"^(\s*)([-*]|\d+\.)\s+(.*)$", line)
if list_match:
ordered = bool(re.match(r"\d+\.", list_match.group(2)))
items: list[str] = []
while index < len(lines):
item_match = re.match(r"^(\s*)([-*]|\d+\.)\s+(.*)$", lines[index])
if item_match:
items.append(item_match.group(3).strip())
index += 1
elif lines[index].startswith((" ", "\t")) and lines[index].strip() and items:
# continuation of the previous item
items[-1] += " " + lines[index].strip()
index += 1
else:
break
blocks.append(list_block(items, ordered))
continue
# paragraph: consume until a blank line or a new block starts
paragraph: list[str] = []
while index < len(lines):
current = lines[index]
if not current.strip():
break
if re.match(r"^(#{1,6}\s|```|\||>|\s*([-*]|\d+\.)\s)", current.strip()):
break
if re.fullmatch(r"-{3,}", current.strip()):
break
paragraph.append(current.strip())
index += 1
if paragraph:
blocks.append(paragraph_block(" ".join(paragraph)))
return title, subtitle, "\n\n".join(blocks) + "\n"
# ------------------------------------------------------------------- validate
def check(markup: str) -> list[str]:
"""Catch malformed block markup before WordPress does.
Gutenberg is unforgiving about its own comments: an unbalanced `<!-- wp:x -->`
turns the rest of the post into one "This block contains unexpected or invalid
content" error, and you get to find out by pasting into a live editor. These
three checks cost nothing and catch every mistake I actually made.
"""
problems: list[str] = []
# 1. every block comment is closed (void blocks self-close and are exempt)
void_blocks = {"separator", "spacer", "image", "html"}
opened = collections.Counter(re.findall(r"<!-- wp:([a-z-]+)", markup))
closed = collections.Counter(re.findall(r"<!-- /wp:([a-z-]+)", markup))
for name in sorted(set(opened) | set(closed)):
if opened[name] != closed[name] and name not in void_blocks:
problems.append(
f"block '{name}': {opened[name]} opened, {closed[name]} closed"
)
# 2. no BARE angle brackets left in text content -- `a < b`, `-->`, a stray
# `>` from a quote marker. Note the limit: this strips anything shaped like
# a tag first, so it cannot tell a leaked `<div>` from an intended one.
# It catches unescaped comparisons and arrows, which is the failure mode
# that actually happens when a code span dodges escaping.
text_only = re.sub(r"<!--.*?-->", "", markup, flags=re.S)
text_only = re.sub(r"</?[a-z][^>]*>", "", text_only)
for number, line in enumerate(text_only.splitlines(), start=1):
if "<" in line or ">" in line:
problems.append(f"line {number}: unescaped angle bracket in text: {line[:60]}")
# 3. block attributes must be valid JSON, or the editor rejects the block
for attrs in re.findall(r"<!-- wp:[a-z-]+ (\{.*?\}) -->", markup):
try:
json.loads(attrs)
except json.JSONDecodeError as exc:
problems.append(f"invalid block attribute JSON {attrs!r}: {exc}")
return problems
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("source", help="markdown file")
ap.add_argument("--out", default=None, help="output .html (default: <source>.wordpress.html)")
ap.add_argument("--lang", default="", help="default language for unlabelled code fences")
ap.add_argument("--check", action="store_true", help="validate the markup and exit non-zero on problems")
args = ap.parse_args()
source = Path(args.source)
title, subtitle, body = convert(source.read_text(), default_lang=args.lang)
problems = check(body)
if problems:
print(f"{len(problems)} problem(s) in the generated markup:")
for problem in problems:
print(f" {problem}")
if args.check:
sys.exit(1)
elif args.check:
print("markup OK: blocks balanced, text escaped, attributes valid JSON")
out = Path(args.out) if args.out else source.with_suffix(".wordpress.html")
out.write_text(body)
blocks = body.count("<!-- wp:")
print(f"wrote {out} ({blocks} blocks, {len(body.split())} words)")
print()
print("Post title (paste into the WordPress title field, NOT the body):")
print(f" {title}")
if subtitle:
print("\nSuggested excerpt:")
print(f" {subtitle}")
print()
print("To import: WordPress editor -> Options (three dots) -> Code editor,")
print("paste the file's contents, then switch back to Visual editor.")
if __name__ == "__main__":
main()