x‑hakt

Teaching the harbour master to speak WordPress

infrastructure

nextjstypescriptdocker

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 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 . None of mine are. This blog is Astro, my business site is a 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.

planner/wp-json/wp/v2/wp-json/wp/v2/wp-json/wp/v2x-haktbusiness sitegame sitenone of themrun WordPress
The planner only ever speaks one protocol. Each hull answers it at the door and translates behind that.

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 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 , 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 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 , appends, because the earlier hops are whatever the client felt like typing.

plannerWordPressconnectorGET /users/medoes the login work?GET /typeswhat kinds of post?GET /categories, /tagsfill the dropdownsPOST /mediafeatured image, raw filePOST /<type>title, HTML, status, idsdoorwayx-hakt.combusiness sitegame siteno edit, no delete404 until configured10 bad logins / 15 min= locked outslug taken? it gets -2, nothing is ever overwritten
The whole conversation. Anything else gets a 404.

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 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.

x-hakt.comAstro, MDX filesHTML to MDXheadings from h2braces escapedrender checkcommit + pushbusiness siteNext.js, HTML storeactive bits strippedcategories to topicstags to levelssaved as HTMLgame siteNestJS servicessame service callsthe CMS editor usescreated as mepermissions applyaudit log entry
Same five words in, three different ways of writing them down.

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 on an internal 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.

no route outnew imagebuilt, not deployedlast night’s backuprestoredthrowaway Postgresinternal networkbridge run3 articles incheckedstatuses + authorthrown awayall of it
A dress rehearsal on last night's data. Production only saw it after this came back clean.

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 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.

notes,create-onlyrich editormodesitepreviewimageuploadsglossaryhoversthree bridgesthree bridgesthree bridgeseach stage landed on all three, not just one
Nothing here is x-hakt-specific. Every capability the editor learned, all three bridges picked up.

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.

draft htmlfigures + termsrich modePOST …/previewbridge route30-min tokenthe real pagesame template as liveplanner previewpanelwhat you see is what the site will actually render
The loop this note itself went through before you read it: drafted, posted to the preview route, rendered by the real template, checked, then scheduled.

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