Writing a Publish Script That Validates Its Own Input Before the CMS Gets a Chance To

A while back I had a genuinely finished article sitting on disk — the email self-hosting piece, several thousand words, technically accurate, ready to go — and it wouldn't publish. Not because anything was wrong with the writing. Because it existed as a full styled preview page: its own <style> block, an <h1>, a byline in a <div>, DNS records and a Postfix config block sitting in <pre> tags. None of that is content this site's publishing pipeline will actually accept, and the gap between "done" and "publishable" turned out to be exactly the kind of thing worth building a permanent check for, instead of re-deriving by eye every time.

What "publishable" means here

Articles on this site go up through a scoped service account that can only save content using the restricted_html text format — no <pre>, no <div>, no <h1>, no page structure at all. The full allowed list is short: links, emphasis, a handful of quoting and list tags, headings from h2 down. That's a deliberate constraint, not an oversight, and it's not really negotiable from the client side.

The part that makes it dangerous rather than just restrictive is what Drupal does with a tag that isn't allowed: nothing loud. It doesn't reject the request. It saves the content with the disallowed tags quietly stripped out, and the JSON:API call still comes back 201 Created — the same success response you'd get from a clean save. The only way to notice is to go look at the live page and see that a heading is missing, or a whole preformatted block collapsed into one unreadable line.

What the checker caught

Before I wrote the validator, the way to find that out was to publish and then read the result. Now it's one command against the file before anything touches the network. Run against that original preview-page draft, exactly as it sat on disk, this is what it says:

  • Body contains tags not permitted by the restricted_html format: body, div, h1, head, html, meta, p, pre, style, title
  • Allowed: a, blockquote, cite, code, dd, dl, dt, em, h2, h3, h4, h5, h6, li, ol, strong, ul
  • Drupal will silently strip these on save. Fix the content or pass --force to send anyway.

Ten disallowed tags, and more than half of them aren't the obvious offenders. I expected to see pre flagged — that one's well-known by now. I didn't expect the checker to be parsing the entire document, page wrapper included, and calling out html, head, and body as violations in their own right, because as far as the parser is concerned they're just more tags with no permission to exist in a body field. That's the value of running an actual parser against the actual content instead of trusting that I remembered every rule correctly.

Why this needed to be a hard stop, not a reminder

I've been handing more and more of the actual drafting to an agent working alongside me in the terminal, which is exactly the situation where "I'll remember to check the tags" stops being a plan. A rule I have to consciously apply every time is a rule I will eventually skip once, and the failure mode for skipping it isn't a loud error — it's an article that's live, looks subtly broken, and might sit that way until a reader notices before I do. So the check isn't a linter I run when I remember to; it's built into the publish script itself, in the one code path everything has to go through to reach the site at all. Nothing gets to the network without passing it first, or without me explicitly overriding it by name.

The escape hatch is there on purpose, and named accordingly

There's a --force flag, because there are legitimate reasons the checker might be wrong — a tag it doesn't know about that Drupal actually permits, or content where a stripped tag genuinely doesn't matter. But it's spelled --force and not, say, --skip-check, because I want it to read as what it is every time I type it: an admission that I'm overriding a safety check, not a routine flag. In practice I've used it exactly zero times outside of testing the checker itself. The one time it would matter is the one time I'm rushed enough to reach for it without thinking, which is precisely when I most need the check to have already done its job.

Add new comment

Restricted HTML

  • Allowed HTML tags: <a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>
  • Lines and paragraphs break automatically.
  • Web page addresses and email addresses turn into links automatically.
Please share this article on your favorite website or platform.