From Markdown to WordPress without the pain: how my Python blogging system works
For a long time I wrote my articles in Markdown and, when it came to publishing them on WordPress, the same friction showed up every time: diagrams (Mermaid especially) didn't paste in cleanly, images had to be prepared by hand, and the whole flow ended up being a mix of repetitive steps that were easy to break.
The initial motivation was very simple: I wanted a tool that, given a Markdown document with diagrams, would generate the images and prepare content ready to embed in WordPress. No endless copy-pasting, no scattered scripts, and no relying on remembering every step.
Over time, that initial script grew into something more ambitious: a complete system for managing several blogs from the terminal, each with its articles versioned in multiple languages, controlled publishing to one or more WordPress servers, assisted translation, status checks and backups.
The result is a Python CLI called blog.py.
What problem it solves
In my case the bottleneck wasn't writing, it was operating:
- converting Mermaid diagrams into images,
- generating a WordPress-compatible version,
- keeping different language versions in sync,
- publishing or keeping drafts safely,
- knowing whether WordPress changed since my last
publish, - publishing the same blog to several servers (mirrors),
- and being able to recover everything from backups.
blog.py unifies all of that under a single command.
The core idea: separate program from data
The piece that made the system grow the most is a simple but powerful distinction: the program lives on one side, the data on another.
- The program (the
.pyfiles and theMakefile) lives at the repository root. - The data lives per site: each independent content blog hangs off
sites/<site>/.
templates/post.md # shared template for 'new'
siteconfig.py # loads sites/<site>/site.toml + .env
sites/
raulsa8/ # personal site (raulsa8.blogs.uv.es)
site.toml # languages, [[servers]], Telegram
.env # secrets (out of git): passwords, token
posts/<post>/
<base>-en.md # English version
<base>-es.md # Spanish version …
<base>-<lang>.<server>.state.json
build/ backup/ # generated (gitignored)
veg/ # another site, another blog
This cleanly separates two kinds of configuration:
- What depends on the blog (site): the content, the frontmatter, the diagrams and the supported languages. Defined in
site.toml. - What depends on the server: the WordPress URL, the user, the password and all the state (
post_id, media, link). That's why state is stored per server.
Multi-site and multi-server
A single program manages several blogs, and each blog can be published to several WordPress servers at once (mirrors):
./blog.py -s raulsa8 publish my-post en # explicit site
./blog.py publish my-post en # default site, to ALL its servers
./blog.py -S mirror publish my-post en # to a specific server-s/--sitepicks the blog. If you don't specify it, the one withdefault = trueis used, or$BLOG_SITE, or the only one available.-S/--serverpicks the server. For reads (diff,status,tg-check) the primary is used; for writes (publish,unpublish) it goes to all servers unless you name one.
The per-server state (<base>-<lang>.<server>.state.json) holds the post_id, the uploaded media (with their sha256) and the link on that server. If the file doesn't exist, that post has never been published there.
Technical philosophy: Python orchestrates, Make executes
The idea is this: Python as a friendly orchestration layer, Make as the execution engine.
Most subcommands delegate to make, so no previous functionality is lost and pieces that already worked aren't rebuilt. But the trickiest logic (translation, diff, status, tg-check, where, edit) lives directly in Python to simplify maintenance.
In other words:
makeis still the technical backbone,blog.pyadds ergonomics, autocompletion and a coherent interface.
Language always explicit
A deliberate design decision: there is no default language. Every command requires the language explicitly (or --all-lang for all versions). That way you never publish the wrong version by accident.
Typical workflow
A typical real flow might be:
./blog.py new my-post en
./blog.py edit my-post en
./blog.py render my-post en
./blog.py publish-draft my-post enAnd when you want to scale it to several languages:
./blog.py translate my-post es --from en
./blog.py translate my-post ca --from en
./blog.py publish-allThe advantage is that you don't have to remember scattered commands or internal paths: the CLI concentrates the mental model.
Autocompletion with TAB
One of the details that boosts day-to-day productivity the most is autocompletion of sites, servers, post names and languages.
Since blog.py is built on argparse, autocompletion is provided by argcomplete: this system already has its global hook installed, so it's enough to open a new shell and use:
./blog.py <TAB>If it isn't active in your session, you can enable it like this:
eval "$(register-python-argcomplete ./blog.py)"It seems like a small detail, but it cuts down on silly mistakes and speeds up daily operation a lot.
Main subcommands (and what they're for)
blog.py exposes a set of commands that cover the entire content lifecycle:
list: lists available articles and languages.new: creates an empty post from the template.render: generates diagrams and buildswordpress.md.extract: extracts Mermaid and generateswordpress.md.diagrams: renders only the Mermaid diagrams to images.publish: creates or updates and publishes live.publish-draft: creates or updates as a draft.publish-all: publishes all versions of all articles.unpublish: reverts content to draft state.translate: translates with Claude and uploads as a draft.master: shows or sets the master language of an article (the source for translations).diff: shows differences between local and WordPress.status: checks (in parallel) whether WordPress was modified since our lastpublish.pull: shows (without integrating) the changes made by hand on WordPress.bad-slugs: detects collided slugs (suffix -2/-3) so translations can be linked.lang-check: verifies the actual language of published posts.tg-check: verifies that the Telegram bot token works.backup: downloads the whole blog tobackup/.export-static: generates a static HTML blog instatic/.clean/distclean: clean thebuild/of one version or the whole thing.where: prints the exact path of the file (e.g. to open it withvim).edit: opens the file in your$EDITOR.
Not every command is used daily, but having them unified avoids ad hoc solutions.
Multilanguage and versioning: the part that adds the most value
As a technical blog grows, keeping versions in several languages gets hard without structure.
This system solves it by leaning on two pillars:
- article files managed explicitly per language,
- Git versioning to preserve traceability, review and rollback.
That allows working incrementally: you write in a base language, translate when it makes sense, review differences and publish each version under control.
Since I use the free Polylang, assigning the language and linking translations is done once, by hand in wp-admin. From then on, publish updates the content without losing that link.
Publishing with a safety net
In publishing, the question isn't whether something will go wrong, but when. The system accounts for this in two ways.
First, publish aborts if WordPress has been modified since the last publish, so it doesn't trample manual changes (you can force it with --force if you know what you're doing). The status command warns you about those modifications before touching anything, and pull shows them as a diff (without integrating them). On top of that, publish is idempotent: it stores a signature of the content and only uploads what changed, so two publish-all runs in a row don't redo work. And publish-all publishes articles in parallel (4 at a time by default).
Second, the backup command is not an afterthought: downloading the full state of the blog to a local folder (backup/) adds a real safety net for migrations, quick audits or recovery after errors.
Exporting to static HTML
Besides publishing to WordPress, the system can generate a complete static HTML version of the blog with export-static.
It renders all articles and produces a static site in sites/<site>/static/: one page per article and language, per-language indexes, a language selector, and no comments. Search relies on Google's site: operator, where base_url is the public URL where you'll host the static version (configurable in site.toml).
It's ideal for keeping a browsable, independent copy of the blog without depending on WordPress to read it.
Telegram integration
Beyond the publishing flow, the system covers the operational side of Telegram: when publishing it can notify a channel, with the chat configurable per language.
The tg-check subcommand validates that the bot token works correctly, which is very useful when you have notification automations around the editorial process. In practice it saves you from discovering a credentials problem too late: you can check the integration before publishing and keep the toolchain healthy.
Why this approach works for me
It's not a giant framework or a closed platform. It's a small, practical system made of well-known pieces:
- Markdown as the source of truth,
- Mermaid for diagrams,
- Python to orchestrate,
- Make to execute,
- WordPress (one or several) as the destination,
- and static HTML as a browsable copy.
That combination has an important virtue: it reduces friction without sacrificing technical control.
Closing
The tool was born to solve a specific problem (turning Markdown with diagrams into a WordPress-publishable format) and ended up becoming a complete editorial production chain from the terminal: several blogs, several languages, several servers and static export, all under a single command.
If you like writing locally, versioning everything and publishing with confidence, an approach like this makes the difference: less mechanical work, fewer mistakes and more focus on what really matters, which is the content.

Figura 1