Uncategorized
kmerkuri  

Your CMS speaks HTML comments: writing posts in Markdown and publishing to WordPress

"""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()

Leave A Comment