Teaching the harbour master to speak WordPress
For a while, getting anything out meant three different browser tabs. A note here had its own editor and its own login. The business site’s explainers went through a different admin entirely, images uploaded one at a time. The game site’s queue was its own CMS, with its own idea of what a category was. None of the three had ever heard of each other, and every one of them wanted roughly the same five things: a title, a body, some tags, a picture and a status. Doing that by hand, several times a week, across three logins that all wanted the same shape of thing in slightly different words, was the sort of chore that eats an evening and teaches you nothing.
The social planner I self-hostedYou run the software on your own machine instead of paying someone to run it for you. More control and no monthly bill; you are the one who fixes it at midnight. was supposed to be the fix: one calendar, one place to draft, the harbour master for every hull. It can publish to Instagram, Facebook and about twenty other places, and it can publish to websites as long as the website is wp-restWordPress's own REST API: a fixed set of URLs under /wp-json/wp/v2/ for posts, media, users and so on. Anything that answers the same shape looks like WordPress to software that only knows how to talk to WordPress.. None of mine are. This blog is Astro, my business site is a Next.jsA widely used framework for building websites and web apps in React — it handles routing, rendering pages on the server, and the build step. app with its own CMS, and the game site is a pile of NestJS services behind a gateway. So rather than teach the planner three new languages, I taught three sites a few words of WordPress: each one grew a thin bridge at the door that answers exactly what the planner expects and translates it into whatever that site actually does with a new piece of content.
Why a fake WordPress, and not three real integrations
Each site already has a real way of creating content, and none of them are the same. This blog writes an mdxMarkdown that can also contain components, like the diagrams and hover definitions on this page. Powerful, and fussy about stray curly braces. file into its notes collection. The business site calls its own createBlogPost or createLearnArticle, depending on which tier it is. The game site creates an article through its content service’s existing APIA defined way for one program to ask another for data or an action — usually over the web, request in, structured response out. "Steam’s API" is how other software reads Steam data., the same one a human editor’s post goes through, so nothing skips the review path the editors already trust. Teaching the planner all three of those shapes would mean carrying three bespoke integrations inside a tool I don’t otherwise want to maintain. Building the translation once on each site, into a shape the planner was already fluent in, meant touching the planner exactly once.
Five words is enough
The planner’s WordPress connector doesn’t want much. It asks /wp-json/wp/v2/users/me whether the login works, asks /types what kinds of post exist, fetches /categories and /tags for its dropdowns, uploads a featured image by posting the raw file to /media with the filename in a Content-Disposition header, and finally posts the article to /wp-json/wp/v2/<type> with a title, some HTML, a status and the ids of whatever it picked. That’s the whole conversation. Each site’s doorway answers exactly those calls and nothing else.
There are no edit routes and no delete routes. A bridge can create an article and that’s it; if the slug is taken it gets -2 on the end rather than overwriting anything. Each doorway is a 404 until its credentials are set, and those credentials are one set per site, sitting in a locked-down file, never anyone’s actual login. It compares the basic-authThe simplest HTTP login: a username and password, base64-encoded, sent in a header on every request. Fine over HTTPS, useless without it. header in constant time and locks an address out after ten bad logins in fifteen minutes. The address it locks out is the last hop in X-Forwarded-For, the one Traefik, the reverse proxyThe doorman in front of your containers. One program takes every incoming web request, terminates HTTPS, and routes each domain to the right container. Traefik and Caddy are the usual picks., appends, because the earlier hops are whatever the client felt like typing.
Three accents
This blog’s doorway turns the planner’s HTML into markdown, starting headings at level two because the title is the page’s h1, and escaping every curly brace and angle bracket in the prose, since MDX reads those as code and will cheerfully fail to render a sentence about {x}. The first paragraph becomes the summary. A draft goes where drafts go, a publish goes through the same render check the editor runs, and both are committed and pushed by the same save queue, so a note from the planner leaves the same trail in gitThe tool that tracks every change to your code and lets you go back to any earlier version. The history lives in a hidden .git folder inside the project. as one I typed.
The business site already stores articles as HTML, so that doorway mostly gets out of the way. It maps the planner’s categories to the Learn section’s topics and its tags to levels, and strips anything active out of the HTML first, because those articles are rendered raw.
The game site was the careful one, because it has users. Its doorway lives inside the content service and creates each article as my account by calling the same service methods the CMS editor calls, so every permission check and workflow step applies as if I’d written it by hand. Draft stays a draft, pending goes into review, publish publishes. Every creation lands in the audit log. Before it went anywhere near production I built the new image, restored the previous night’s database backup into a throwaway PostgresPostgreSQL — a widely used database. If a project stores accounts, orders, posts, anything structured, it is probably in a Postgres container. on an internal DockerA tool that packages an app together with everything it needs to run into a "container", so it runs the same on any machine and does not collide with anything else installed. network with no route out, ran the bridge against that, checked the three articles came out with the right statuses and author, and threw the lot away.
The domain with a path in it
One small gift from the connector: it builds URLs by gluing the “domain” you give it to /wp-json/..., and its validation happily accepts a domain with a path on the end. So the game site’s channel is https://api.playtopia.com.au/api/v1/wp-bridge, the gateway forwards anything under wp-bridge to the content service, and nobody had to invent a new hostname. The code is more what you’d call guidelines than actual rules, and for once that worked in my favour.
What grew on top of create-only
The first version of each bridge was barely more than a doorway: accept a title and a body, write the thing, done. It was enough to post a note, and not enough to post a good one. The planner’s own editor turned out to be the bigger problem. Its plain mode kept paragraphs, headings, bold, links and little else; everything that made a note worth reading, code blocks, blockquotes, figures, tables, inline SVGs, got stripped on the way out, and whatever survived had its angle brackets unescaped afterwards, a landmine for anything that still had a stray < in it. So that got fixed first: a second editor mode, used only for these three channels, that keeps a sanitised allowlist wide enough for a real article, running on a forked build of the planner pushed to GHCRGitHub Container Registry — GitHub’s own place to publish container images, next to the code they came from. Push a tagged release and a workflow can build and land one there automatically. and pinned like anything else in the fleet.
Everything after that landed the same way: once on the editor, then once on each bridge, in order. A preview route, so a draft could be checked as the actual page before it went anywhere. Image uploads through the same media route the editor already called. Glossary hovers, the dotted underlines under this very paragraph, which needed the editor itself to learn to protect a <span data-term> through an edit, not just hope the markup made it through unscathed.
Proving the preview isn’t lying
The preview route is the one I trust the least by instinct and the most in practice, because it doesn’t render a guess at the page, it renders the actual page. A draft posts to POST .../wp-json/wp/v2/preview; the bridge stores it behind a token that expires in half an hour and hands back a URL. On this blog that URL is served by the same note template a published page uses. On the business site it’s the same article template. On the game site it’s literally the same component that renders /news/[slug], just fed a draft instead of a published row. What shows up in the planner’s preview panel is not a simulation of what the site will do with the draft. It is what the site did with the draft, a minute ago, on a URL nobody else can reach.
All three bridges answer the same route shape today, each one tested against its own site, and this note went through that loop before it was scheduled. What nothing has tested yet is the reason the shape was built this way: that a fourth hull, if one ever joins the fleet, costs one more bridge and not a fourth integration. Three sites, one planner, and not a line of PHP anywhere near the harbour. Fair winds.
-x