HTML Publisher

WordPress provides the content. You provide the complete HTML structure.

Clone me on GitHub
{{ sl.label }}
{{ counter }}

Elbutschitos HTML Publisher – WordPress Content. Your HTML.

WordPress manages your content. You control the HTML.

This tutorial covers every feature of the plugin, from a minimal first page to advanced URL rewriting and HTML cleanup. Features are marked Basic or Advanced throughout, so you always know what you actually need for your current step — and what you can safely skip until later.

1. How HTML Publisher Works

HTML Publisher uses WordPress purely as a content management system. Your generated website doesn't use WordPress's frontend, the active theme, or the WordPress template hierarchy — it uses the plain HTML template you build yourself.

Not a Traditional WordPress Static Site Generator

HTML Publisher takes a fundamentally different approach from traditional WordPress static site generators such as Simply Static or Staatic.

These tools typically take the website generated by WordPress — including its theme, templates, menus, and frontend output — and convert that website into static files.

HTML Publisher works differently:

WordPress is the CMS. The HTML is yours.

WordPress provides the content. You provide the complete HTML structure that will be published.

There is no WordPress theme involved in the generated website. No WordPress template hierarchy is rendered. No WordPress frontend needs to exist on the published site.

In other words:

Traditional static site generator:

WordPress
   ↓
Theme + templates + WordPress frontend
   ↓
Generated website
   ↓
Static HTML

HTML Publisher:

WordPress
   ↓
Content / REST API data
   ↓
Your HTML template
   ↓
Static HTML

HTML Publisher does not turn a WordPress website into a static website. It uses WordPress as a content source to generate a website whose HTML you define yourself.

You don't need to know WordPress theme development, PHP, or the WordPress template hierarchy to use this plugin. You only need to know HTML, and be willing to place a few placeholders in it.

Every generated page goes through the same pipeline:

WordPress content
       ↓
WordPress REST API data
       ↓
your HTML template + your injection rules
       ↓
static HTML
       ↓
Netlify / SFTP

WordPress's REST API is simply the data source HTML Publisher reads from internally — you don't need to develop against it, call it yourself, or understand it in depth. Think of it as: WordPress holds the content, and HTML Publisher hands you the pieces of it you ask for.

What this means in practice:

  • You build your frontend as ordinary HTML — no WordPress theme required.
  • No PHP templating is required to design a page.
  • The placeholders you use in your HTML (called markers) are plain text you choose yourself.
  • WordPress does not generate your frontend — your HTML template does.
  • HTML Publisher combines your WordPress content with your HTML template to produce the static files.
  • The published website needs neither WordPress nor PHP to run. The one exception: if you use the optional PHP form handler for an SFTP target (Section 8), that one script needs PHP on the hosting side — nothing changes about the rest of the site.

Prerequisites Basic

  • WordPress 6.0 or newer
  • PHP 7.4 or newer on the WordPress installation itself
  • The HTML Publisher plugin, installed and activated like any other WordPress plugin
  • Recommended: add a CONTENT2HTML_ENCRYPTION_KEY constant to your wp-config.php before entering SFTP or Netlify credentials — without it, your secrets are still encrypted, but with a key that's auto-generated and stored in the database itself.
define('CONTENT2HTML_ENCRYPTION_KEY', 'a-long-random-value');

2. What's Basic and What's Advanced

You don't need everything in this tutorial for your first website. Here's the map:

Basic — everything you need for a working site:

  • Your own HTML template with markers
  • Data injection rules for simple fields (title, content, date, …)
  • Multiple templates that can be assigned to individual pages or posts
  • A flat, single-level navigation menu
  • Forms (enabling them is a checkbox and a few settings fields)
  • Deployment (Deploy all / Deploy assets only / single-page deploy)

Advanced — add these once you need them:

  • Linked REST resources (featured images, categories) in data injection rules
  • Multi-level navigation (parent items, submenus)
  • Multi-column footers grouped by topic
  • URL & Asset Mapping
  • HTML Tidy Rules
  • The PHP form handler's custom target option

If a heading below is marked (Advanced), feel free to skip it on a first read.

3. Quickstart Basic

Step 1 — Build a minimal template

Create an .html file with a few markers:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>###title###</title>
</head>
<body>
    <h1>###title###</h1>
    <div>###content###</div>
</body>
</html>

Step 2 — Configure the plugin

Go to Settings → HTML Publisher → Content:

  • Under Post Types, check Posts and/or Pages — whichever content should be published.
  • Under Template file, upload the HTML file from Step 1.
  • If your template references its own CSS, JavaScript, fonts, or images (e.g. Bootstrap, a custom stylesheet), upload those separately under Assets (CSS/JS/Fonts/Images).
    ⚠️ Warning

    The ZIP's root folder must be named assets — this trips people up. You're not zipping your CSS/JS/font files directly; you're zipping one folder called assets that contains them. Concretely:

    ✅ correct — assets/ is the top-level folder inside the ZIP:
    my-upload.zip
    └── assets/
        └── bootstrap/
            └── css/
                └── bootstrap.min.css
    
    ❌ wrong — the contents are zipped directly, without the assets/ wrapper:
    my-upload.zip
    └── bootstrap/
        └── css/
            └── bootstrap.min.css

    If you get this wrong, HTML Publisher won't fail silently — it rejects the upload with a clear message telling you exactly what's missing, so you'll know immediately if a ZIP needs fixing.

    Beyond that one required assets root, the folder structure inside is entirely up to you — it just has to match what your template actually references. A template linking assets/bootstrap/css/bootstrap.min.css needs that exact path inside the ZIP. HTML Publisher doesn't fetch libraries like Bootstrap for you; you download them yourself and upload them this way.

  • Under Data injection rules, add:
title->rendered => ###title###
content->rendered => ###content###
  • Save.

Step 3 — Deploy

Set up a deployment target under Settings → HTML Publisher → Deployment target (see Section 11 for details on Netlify vs. SFTP), then click Deploy all.

HTML Publisher writes one static HTML file per post/page into a build folder, mirroring your WordPress permalink structure (e.g. /my-post/index.html for "Post name" permalinks), and uploads the whole build to your target.

Quickstart — template, settings, deploy

4. Data Injection Rules Basic

HTML Publisher doesn't render your content through a WordPress theme. Instead, it reads every post or page's data and injects the values into your HTML template wherever you've placed a marker.

A data injection rule looks like this:

title->rendered => ###title###
content->rendered => ###content###
  • The left side is a path into that post's data, using -> to step into nested fields.
  • The right side is the marker — the exact text from your template that gets replaced with that field's value.
Data injection rules in the settings screen
Note

You don't need to memorize the WordPress REST API structure. You're picking out the pieces of data you want to use in your HTML — title, content, date, an image — not learning to program against an API.

Next to the Data injection rules textarea, a "Browse available fields..." button opens a field browser: pick an example post/page from a dropdown, and it shows you exactly what's available for it.

The "Browse available fields..." button
  • A Common fields shortlist appears first — title, content, excerpt, slug, link, date — with clean, ready-to-use marker names (the same ###title###-style names used throughout this tutorial). One click inserts the rule as a new line in the textarea.
  • Below that, all fields are listed with a short live preview of their actual value, filterable by a search box, with a toggle to also show empty fields (hidden by default to keep the list manageable).
  • The four built-in linked fields — author, featured image, categories, tags — are automatically resolved and shown already in the sourcePath|endpoint|dataPoint form the Advanced subsection below explains, marked "resolved link". You don't need to construct that syntax by hand for these; just click the row.
  • A field that's a plain list of IDs (e.g. a custom taxonomy) is shown but marked "array" and isn't clickable-to-insert — used directly, it would just produce the literal text "Array" in your output. For those, build a rule with the | syntax manually, the same way the built-in ones work internally.
The Available fields browser: example post/page dropdown, Common fields shortlist, and the filterable All fields list
The field browser

If you'd rather work outside wp-admin, WordPress itself always lets you inspect the raw data for any post directly in your browser: visiting https://your-site.com/wp-json/wp/v2/posts/<id> shows the same field names and structure the field browser reads from. The HTML Publisher Playground offers a similar field helper too, for exploring a REST API URL without wp-admin access at all.

Common fields

title->rendered => ###title###
content->rendered => ###content###
excerpt->rendered => ###excerpt###
slug => ###slug###
link => ###permalink###
date => ###date###

date (and any other field whose value matches an ISO 8601 timestamp, e.g. 2024-01-15T10:30:00) is automatically reformatted using the Date format setting on the same tab (default pattern: d.m.Y) — one exception is Yoast's yoast_head field, which is left untouched even if it happens to contain a matching string somewhere inside it.

You're not limited to WordPress' own fields — anything present in the post's REST API response works, including fields added by other plugins (e.g. Yoast SEO's yoast_head, or ACF fields exposed to REST).

Errors show up in the output, not silently

If a rule points at a field that doesn't exist, the marker is replaced with a visible string like No such property title->rendered instead of failing silently or leaving the marker untouched — worth knowing so you notice it during testing rather than after deploying.

Advanced: Linked resources Advanced

The rules above cover fields that live directly on the post. Some data — the featured image, categories, tags, the author — is stored differently: the post only holds an ID pointing at another endpoint. Resolving these needs one extra step, and is where the syntax gets more advanced.

Note

For these four fields specifically — author, featured image, categories, tags — the field browser from above already does this resolution for you and lists the ready-made rule. The manual syntax below is what it's doing behind the scenes, and what you'll still need for anything the browser doesn't auto-resolve (a custom taxonomy, for instance).

Extend the rule with two extra segments separated by |:

sourceField|endpoint|fieldOnThatEndpoint => ###marker###

HTML Publisher reads sourceField's value from the post, then resolves that ID against the other endpoint and pulls fieldOnThatEndpoint from the result.

Featured image (single relation — featured_media is a single ID):

featured_media|media|source_url => ###featuredImage###

Author (single relation):

author|users|name => ###author###

Categories (a post can have several — categories is an array of IDs):

categories|categories|name => ###categories###
⚠️ Warning

When a field holds multiple IDs (categories, tags), every resolved value is concatenated directly into the marker with no separator — TechDesignMarketing rather than Tech, Design, Marketing. There's currently no way to configure a separator or a per-item wrapper through the settings screen. This syntax is most reliable for single-value relations (like the featured image); for multi-value ones, treat the result as a single joined string, or design your template/CSS around that.

5. Multiple Templates per Post/Page Basic

By default, every post/page uses the one template file configured under Settings → HTML Publisher → Content → Template file. For cases where some pages need a different layout — a landing page, a product page, anything visually different from your standard post template — you can add extra templates and assign them individually.

Adding an additional template

Still on the Content tab, under Additional templates:

  • Name — required; this is what shows up in the per-page dropdown, so make it recognizable (Landing page, Product page, …).
  • File — the .html template itself, same rules as the default template (markers, <head>/<body> structure, etc.).

Save settings, and the new template appears in the list, with a checkbox to remove it again later.

Additional templates setting: a table of template names and files with Remove checkboxes, plus fields to add a new template
Additional templates on the Content tab

Assigning a template to a specific post/page

Open the post or page, and in the HTML Publisher sidebar box you'll now see a Template for this page dropdown — Default, plus every additional template you've added. Pick one and click Update/Publish.

The HTML Publisher meta box on a post edit screen with the open 'Template for this page' dropdown listing Default, Alternative and Test-Alternative
Template for this page, in the post sidebar
Note

Important: this selection is saved as normal post meta through WordPress' own save process — it takes effect the next time you save the post, not immediately when you press Deploy. If you switch the template and immediately hit Deploy without saving first, the previous template is still used.

One shared set of marker rules

There's only ever one list of Data injection rules — it's not per-template. Every template (default and additional) is matched against the same global rule set. In practice this just means:

  • If a template doesn't contain a particular marker at all, that rule simply has nothing to replace there — no error, nothing happens.
  • If a new template needs a field none of your existing rules cover, add that rule once, globally — it becomes available to all templates, not just the new one.

6. Navigation

This example shows a WordPress menu being turned into the navigation defined by your HTML template.

Before diving in: this entire section is optional. Since your template is just plain HTML, a completely static, hand-written menu works exactly as well — no markers, no WordPress menu, nothing to configure:

<nav>
  <ul>
    <li><a href="/">Home</a></li>
    <li><a href="/about/">About</a></li>
    <li><a href="/contact/">Contact</a></li>
  </ul>
</nav>

This is often the simplest choice for a small site with a menu that rarely changes — you just edit the template directly when it does. If that's all you need, skip straight to Section 7. The automatic system below only pays off once you want editors to manage menu structure and order from WordPress itself (Appearance → Menus) without touching the template, or once the same menu needs to stay in sync across several templates.

The core idea: subparts

Everything in Sections 4–5 replaced a marker with a single value. Navigation works differently: it replaces a subpart — a chunk of your template's HTML — with several copies of itself, one per menu item.

A subpart is the HTML that sits between two identical marker comments:

<!-- ###ITEM### -->
<li><a href="#">Sample Item</a></li>
<!-- ###ITEM### -->

HTML Publisher cuts out everything between the two <!-- ###ITEM### --> comments, treats it as a tiny template of its own, and generates one copy per real menu item — keeping whatever HTML/CSS classes you designed it with. This is a different mechanism from the markers in Section 4: those were single points replaced by one value; a navigation marker is a pair of comments that delimits a block to be duplicated.

Navigation is configured under Settings → HTML Publisher → Navigation, once for Main navigation and once for Footer navigation — two fully independent instances of the same system.

Level 1 — a flat menu Basic

The minimum setup: enable Generate automatically, pick a WordPress menu (created under Appearance → Menus), and define two markers:

  • Wrapper marker — wraps the entire menu area, including its outer element (e.g. the <ul> itself).
  • Item marker — wraps exactly one demo menu item, without children.
<!-- ###MAINMENU### -->
<ul class="nav">
  <!-- ###ITEM### -->
  <li><a href="#">Sample Item</a></li>
  <!-- ###ITEM### -->
</ul>
<!-- ###MAINMENU### -->

(###MAINMENU### and ###ITEM### are simply the defaults — like the content markers in Section 4, these are free text too, as long as they match what you enter in Settings.)

HTML Publisher extracts the item subpart (<li><a href="#">Sample Item</a></li>), duplicates it once per menu item in your chosen WordPress menu, and drops the result in where the item subpart used to be. Your one demo <li> becomes as many <li>s as your menu has entries.

Level 2 — link text, URL, and "active" state are automatic Basic

You don't need markers for the link label or the href — HTML Publisher finds the first <a> tag inside each duplicated item and fills in the real menu title and URL itself. If that page happens to be the current one, it also adds your configured Active class name (from the Navigation tab) to that <a> tag — and removes it everywhere else, even if your demo <a> already had a hardcoded "active" class for design purposes in the WordPress editor preview.

So the sample <a href="#">Sample Item</a> above is deliberately a stand-in — its href and text get overwritten for every generated copy.

Level 3 — multi-level menus Advanced

For menus with sub-items, add three more markers:

  • Parent item marker (optional) — a second demo item, used instead of the plain item template whenever a menu entry has children. Without this, sub-items are simply ignored and the menu renders flat.
  • Submenu wrapper marker — inside the parent item template, wraps the container that holds the sub-items (e.g. a nested <ul>), including that container element itself.
  • Submenu item marker (optional) — a demo sub-item, used inside the submenu wrapper. If left empty, HTML Publisher falls back to reusing the plain Item marker template for sub-items too — but then that same marker text must also appear inside the submenu wrapper block, or the whole submenu wrapper's content gets replaced wholesale instead of just the demo item within it. To avoid that ambiguity, always define a distinct Submenu item marker explicitly.
<!-- ###MAINMENU### -->
<ul class="nav">

  <!-- ###ITEM### -->
  <li><a href="#">Sample Item</a></li>
  <!-- ###ITEM### -->

  <!-- ###PARENT### -->
  <li class="has-children">
    <a href="#">Sample Parent</a>
    <!-- ###SUBMENU### -->
    <ul class="submenu">
      <!-- ###SUBITEM### -->
      <li><a href="#">Sample Subitem</a></li>
      <!-- ###SUBITEM### -->
    </ul>
    <!-- ###SUBMENU### -->
  </li>
  <!-- ###PARENT### -->

</ul>
<!-- ###MAINMENU### -->

With Parent item marker = ###PARENT###, Submenu wrapper marker = ###SUBMENU###, Submenu item marker = ###SUBITEM###. This recurses at every depth — a sub-item that itself has children is automatically rendered using the same parent template again.

Footer navigation

Everything above applies a second time, completely independently, under the Footer navigation fields — different menu, different markers, different template location. Handy when your footer needs a different structure (e.g. columns) than your header nav.

Advanced: multi-column footers grouped by topic Advanced

A common footer pattern — several columns, each with a heading and a handful of links (e.g. "Legal" → Imprint, Privacy, Terms) — maps onto the Level 3 system: each column heading is a top-level menu item using the Parent item marker, and its links are children rendered via Submenu wrapper marker / Submenu item marker.

1. Build the menu — under Appearance → Menus, add one top-level item per column (a Custom Link or an actual overview page works), then drag the individual pages underneath it as children:

Legal
 ├─ Imprint
 ├─ Privacy
 └─ Terms
Company
 ├─ About us
 ├─ Careers
 └─ Contact

2. Template (no flat Item marker needed here — this footer is columns only):

<!-- ###FOOTERMENU### -->
<footer class="text-center bg-body" data-bs-theme="dark">
  <div class="container py-4 py-lg-5">
    <div class="row">

      <!-- ###FOOTERPARENT### -->
      <div class="col-md-4 mb-3">
        <h5><a class="link-body-emphasis text-decoration-none" href="#">Column heading</a></h5>
        <!-- ###FOOTERSUBMENU### -->
        <ul class="list-unstyled">
          <!-- ###FOOTERSUBITEM### -->
          <li class="mb-2"><a class="link-body-emphasis" href="#">Sample link</a></li>
          <!-- ###FOOTERSUBITEM### -->
        </ul>
        <!-- ###FOOTERSUBMENU### -->
      </div>
      <!-- ###FOOTERPARENT### -->

    </div>
    <p class="text-body mb-0">Copyright &copy; 2026 Brand</p>
  </div>
</footer>
<!-- ###FOOTERMENU### -->

With Parent item marker = ###FOOTERPARENT###, Submenu wrapper marker = ###FOOTERSUBMENU###, Submenu item marker = ###FOOTERSUBITEM###. This generates one <div class="col-md-4"> per column, e.g.:

<div class="col-md-4 mb-3">
  <h5><a class="link-body-emphasis text-decoration-none" href="#legal-overview">Legal</a></h5>
  <ul class="list-unstyled">
    <li class="mb-2"><a class="link-body-emphasis" href="/imprint/">Imprint</a></li>
    <li class="mb-2"><a class="link-body-emphasis" href="/privacy/">Privacy</a></li>
    <li class="mb-2"><a class="link-body-emphasis" href="/terms/">Terms</a></li>
  </ul>
</div>
<div class="col-md-4 mb-3">
  <h5><a class="link-body-emphasis text-decoration-none" href="#company-overview">Company</a></h5>
  <ul class="list-unstyled">
    <li class="mb-2"><a class="link-body-emphasis" href="/about/">About us</a></li>
    <li class="mb-2"><a class="link-body-emphasis" href="/careers/">Careers</a></li>
    <li class="mb-2"><a class="link-body-emphasis" href="/contact/">Contact</a></li>
  </ul>
</div>
⚠️ Warning

The <h5> must contain its own <a> tag, even if you don't want it clickable. The heading's title/URL is filled in by finding the first <a> tag in the entire rendered column — heading and children together. Remove the <a> around "Column heading" and that first-match search lands on the first child link instead — the heading's data gets written into the first <li>, and that child silently disappears from the output. If you don't want a clickable heading, keep the <a> tag and neutralize it with CSS (pointer-events: none; cursor: default;) rather than removing the tag.

If your footer needs no heading at all — just a flat row of links — skip all of this and use the plain Level 1 pattern (flat Item marker, no parent/submenu markers).

7. Forms Basic

Forms from your WordPress content continue to work on the static website, without changing the form markup or adding any HTML Publisher markers. A Gutenberg Form block, Contact Form 7, Gravity Forms — whatever you use — needs no special preparation in your content or template.

Setup

  1. Go to Settings → HTML Publisher → Forms and enable Forms.
  2. Make sure a deployment target (Netlify or SFTP) is configured under Deployment target.
  3. Fill in the settings relevant to that target (see table below).
  4. Deploy.
Setting Applies to
Recipient email SFTP only
Sender address SFTP only
Subject prefix SFTP only
Thank-you page Netlify + SFTP
Honeypot field name SFTP only (default: _gotcha)
Custom form target (Advanced) SFTP only

What HTML Publisher does behind the scenes

Normally, a WordPress form submits back into WordPress — which doesn't exist anymore once your site is static and it would otherwise just post into the void. HTML Publisher rewrites every <form> tag it finds on a generated page (wherever it came from — your static template or WordPress content pulled in via a marker) so it works without a WordPress backend.

Both targets:

  • WordPress-internal fields are stripped from the HTML before the page is even written — nonces, AJAX routing fields, plugin-specific tokens, anything starting with _. Your honeypot field is explicitly kept.
  • A small client-side validation script is attached (required-field / "select at least one" checks), with its messages translated into whatever language your WordPress install uses.
⚠️ Warning

Important: In your Netlify project, make sure Enable form detection is turned on. Netlify needs form detection enabled to recognize and process the forms contained in your deployed HTML files.

Netlify: the form gets data-netlify="true", a name attribute (derived from the form's id, or auto-numbered if neither exists), and a hidden form-name field — Netlify's build-time form detector needs all three to pick the form up. The original action (which would point at your WordPress domain) is either removed, so the form just submits to the current page, or replaced with your configured Thank-you page.

⚠️ Warning

If your thank-you page URL happens to point back at your own WordPress domain, the plugin silently drops it instead of using it — a redirect through WordPress would bypass Netlify's form capture entirely, so the submission would work but never appear anywhere in Netlify's Forms overview. You'll also see a warning when saving settings in this case.

SFTP: the action is rewritten to a bundled form-handler.php (or your Custom form target, if set — Advanced), method is forced to post, and an invisible honeypot field is injected. HTML Publisher generates form-handler.php once per full deploy, filled in with your recipient/sender/subject settings, and uploads it alongside your site — it needs a PHP-capable SFTP target to actually run. It also appends a rule to .htaccess blocking direct access to form-handler.log, since that log contains recipient addresses and subject lines.

8. URL & Asset Mapping Advanced

Change-URL rules in the settings screen

WordPress content is full of URLs pointing back at your WordPress installation: image sources, links, srcset variants. On a static site those need to point somewhere else — a different domain, or a different folder structure entirely. Change-URL rules handle this rewriting.

The domain is already handled automatically

HTML Publisher automatically detects your WordPress installation's own domain and strips it from every URL, turning https://wordpress-php83.ddev.site/wp-content/uploads/logo.jpg into the root-relative /wp-content/uploads/logo.jpg. This happens without any configuration — you don't need to write a rule for it.

Writing your own rules

Under Settings → HTML Publisher → Change-URL rules, add one rule per line:

pattern => replacement

The left side is a regular expression, delimited with /, applied to whatever's left of the URL after the automatic domain-stripping above. The right side is the replacement text.

💡 Tip

What is a regular expression?

A regular expression (often shortened to "regex") is a pattern used to describe, find, or match pieces of text. In HTML Publisher, it's used to describe which part of a URL should be replaced.

You don't need to become a regex expert to use this. For simple URL changes, adapting the examples in this tutorial is usually enough.

If you want to go beyond the examples here, searching for "regular expression," "regex tutorial," or "regex tester" will turn up plenty of resources — a regex tester in particular is a good way to try a pattern against a few real example URLs before adding it as a Change-URL rule.

These days, another practical option is simply asking an AI to write the pattern for you, for example:

"Write me a regular expression that replaces /wp-content/uploads/ with /assets/img/ in a URL."

You can then paste the result into a HTML Publisher Change-URL rule — but always test it against a few of your own example URLs first, rather than assuming it's correct as-is.

One HTML Publisher-specific detail to keep in mind either way: the plugin uses / as the regex delimiter, so any literal / inside your pattern usually needs to be escaped as \/. More on that right below.

/\/wp-content\/uploads\// => /assets/img/
⚠️ Warning

Because / is the delimiter, any literal / inside your pattern must be escaped as \/. A pattern like /wp-content/uploads/ (unescaped) is not a valid delimited regular expression and is silently ignored — it won't show up as an error, the rule just never applies. Always escape internal slashes, as in the example above.

This rule is applied wherever a URL on the page still points at your WordPress domain: <a href>, <img src> and srcset, content attributes (used for Yoast's meta tags, among others), and Yoast's schema.org <script> block.

What actually gets copied — and what doesn't

URL rewriting by itself only changes text: an <a href>, an img src, a content attribute — the rule above rewrites the URL wherever it appears, but nothing about the underlying file changes.

Note

Automatic asset copying applies to images only. When a URL in an <img> tag or in srcset is rewritten, HTML Publisher additionally copies the corresponding image file itself to the new location in the build directory. This applies specifically to image assets referenced via <img src> or srcset — it is not a general "detect and copy any linked file" mechanism. A file referenced via <a href> (a PDF, a download, anything else) has only its URL rewritten; the file itself is not copied automatically.

So for <img> elements (both src and every variant inside srcset), HTML Publisher doesn't just rewrite the URL text — it also copies the actual image file and places it in your build folder at the new, rewritten path:

WordPress source:
/wp-content/uploads/2026/09/logo.jpg

Rule:
/\/wp-content\/uploads\// => /assets/img/

Resulting URL in the HTML:
/assets/img/2026/09/logo.jpg

File copied to (inside the build folder):
assets/img/2026/09/logo.jpg

With this in place, /wp-content/uploads/ — a dead giveaway that a page came from WordPress — can disappear from your published site entirely. This is a direct expression of the plugin's core principle: WordPress provides the content. You control the final HTML — right down to the URL structure.

A concrete example: a PDF linked via <a href="/wp-content/uploads/brochure.pdf"> has its URL rewritten by the same rule, but — as noted above — the file itself is not copied to the new location, since it's not an <img>/srcset reference. You'd need to upload it there yourself, or keep it under a path your rules don't rewrite.

9. HTML Tidy Rules Advanced

Tidy HTML rules in the settings screen

WordPress and its plugins add their own markup to your content: loading="lazy" attributes, auto-generated classes like wp-image-2662, and similar. HTML Tidy rules let you clean this up in the generated output, without touching your original WordPress content or your HTML template.

How they work

A rule targets elements using a CSS selector — if you know CSS selectors, you already know the syntax used here. What's different from ordinary CSS is what happens once an element is matched: unlike a simple text search-and-replace, Tidy rules operate on the actual DOM — the parsed structure of HTML elements — after your content has been injected into the template, and then perform an operation on the matched elements:

CSS selector | operation,property[,value]

Two operations are available:

  • remove — removes an attribute from every matched element.
  • change — sets an attribute to a specific value on every matched element.

Under Settings → HTML Publisher → Tidy HTML rules, one rule per line:

img|remove,loading
img.wp-image-2662|change,alt,TEST-ALT-TEXT

Rule 1 — img|remove,loading: find every <img> element and remove its loading attribute.

Rule 2 — img.wp-image-2662|change,alt,TEST-ALT-TEXT: find <img> elements with the class wp-image-2662 and set their alt attribute to TEST-ALT-TEXT.

Before:

<img
    class="wp-image-2662"
    src="/assets/img/example.jpg"
    loading="lazy"
    alt="Old text">

After both rules are applied:

<img
    class="wp-image-2662"
    src="/assets/img/example.jpg"
    alt="TEST-ALT-TEXT">

Because these rules run on the DOM rather than on raw text, a selector like img.wp-image-2662 only matches actual <img> elements carrying that class — it won't accidentally match the same text appearing somewhere else on the page (e.g. inside a code sample or a comment).

Removing and renaming WordPress's own CSS classes

WordPress and page-builder plugins add their own classes to elements — wp-block-columns, wp-image-2662, Spectra's uagb-* classes, and similar. Two settings on the same tab handle these, and they work together:

Remove CSS class prefixes — a plain comma-separated list (e.g. wp-, uagb-). Any class starting with one of these prefixes is stripped from every element, site-wide. Leave it empty to keep all classes exactly as WordPress generates them.

Remove CSS class prefixes field set to wp-, and the Class mapping textarea with wp-block-cover rules and the Browse available classes button
Note

For "Browse available classes..." to work, at least one prefix must be defined in Remove CSS class prefixes. The logic behind it: you remove all classes with a given prefix (e.g. wp-) — except those listed in Class mapping, which are renamed to your class names instead.

Class mapping — a textarea, one rule per line: wp-block-columns => c2h-columns. A class listed here is renamed and kept instead of being removed — even if it also matches one of the prefixes above, the mapping is checked first. This is for layout-relevant classes: WordPress's own CSS for wp-block-columns doesn't exist on your static site, so simply stripping it would silently break a "columns" or "image left, text right" layout. Renaming it to a class you define and style yourself preserves the structure while dropping WordPress's own styling.

A "Browse available classes..." button next to the Class mapping field opens a browser modal: pick an example post/page, and it lists every class actually present on that page's rendered content that matches one of your configured prefixes — each with the tag it's on and a short text preview, so you don't have to already know Gutenberg or page-builder internals to find them. Clicking a row adds it to the Class mapping field with the cursor placed right after =>, ready for you to type the replacement name; classes already mapped are marked and can't be added twice.

The Available classes browser: example post/page dropdown and a list of wp- classes with their tags and text previews, some marked Already mapped

A practical way to use this: build one test page that stacks every layout variant your content actually uses (columns, groups, cover images, media-and-text, …), then run the class browser on it once to map everything relevant in one pass, rather than discovering missing mappings page by page after publishing.

Change-URL rules are the regex mechanism

There's no separate "Regex rules" feature beyond what's described here and in Section 8. HTML Tidy rules work via CSS selectors, not regular expressions. The Change-URL rules from Section 8, on the other hand, are full regular expressions already — that's the plugin's one regex-based mechanism, and it's specifically for URLs.

10. The Full Transformation Pipeline

Putting Sections 3–9 together, a page goes through these steps on its way from WordPress to your static output:

WordPress
   ↓
your content
   ↓
your HTML template
   ↓
data injection (Section 4)
   ↓
URL & asset rewriting (Section 8)
   ↓
HTML Tidy / DOM cleanup (Section 9)
   ↓
static file
   ↓
Netlify / SFTP

Each step only does its own job — content injection doesn't touch URLs, URL rewriting doesn't touch arbitrary attributes, and Tidy rules run last, on the fully assembled page. You can use as many or as few of these steps as your project needs; everything past Section 4 is optional.

11. Deployment Basic

Step 1 — Choose a target

Under Settings → HTML Publisher → Deployment target, choose either Netlify or SFTP and fill in the credentials. Use Test connection to verify them before deploying.

Step 2 — Deploy all

Under the Deployment section at the bottom of the settings screen, click Deploy all. This regenerates static files for every selected post type and uploads the entire build — the right choice for your first deploy, and for any deploy where content, template, or settings changed broadly.

Note

On Netlify, every deploy replaces the site's entire content, so Deploy all is effectively the only real option there — there's no way to update a single page in isolation on Netlify's side.

An additional Deploy assets only button re-uploads just your CSS/JS/image assets, without touching content pages — handy after a pure design tweak.

Single-page deploy (SFTP only)

For an SFTP target, you can also update just one page: open that post or page and click Deploy in the HTML Publisher box in the sidebar. This regenerates and uploads only that single file — useful for quick content edits once your site is already live. On Netlify, this button exists too, but triggers the same full rebuild as Deploy all, since Netlify has no concept of a partial update.

Build directory

Every generated file — HTML pages, the copied assets/ folder, form-handler.php, the validation script — is written into a local build directory before upload. Deploy all empties this directory first, so it always reflects the current state exactly; a single-page deploy only touches that one file.

12. Markdown Export

Alongside the static HTML pipeline covered in Sections 3–11, HTML Publisher includes a separate, independent feature: exporting your WordPress content as plain Markdown files.

This isn't part of the templated static-site pipeline — it doesn't use your HTML template, markers, Change-URL rules, or Tidy HTML rules, and it doesn't deploy anywhere. It's meant for a different purpose: getting your content out of WordPress in a portable, plain-text format for use in other systems — Hugo, Jekyll, Eleventy, an Obsidian vault, or any documentation tool that reads Markdown.

How to use it

Under Settings → HTML Publisher → Markdown export:

  • Choose whether to download images and rewrite references as relative paths (enabled by default). With this on, images from your own WordPress site are downloaded into the ZIP alongside the Markdown files, and their references become relative paths — so the export works standalone, without a running WordPress instance. With it off, image references are left pointing at your WordPress site as-is.
  • Click Export as Markdown now. The plugin generates one .md file per post/page — for whichever of Posts and Pages are checked in the same setting used by the static-site pipeline (Content tab; there's no support for arbitrary custom post types, here or in the main generator) — zips them, and starts a direct download — no SFTP or Netlify involved.

What you get

One folder per post type (post/, page/, …), one .md file per post, named after its slug (or its ID, if the post has no slug). Each file starts with a YAML front matter block:

---
title: "My First Post"
date: 2026-01-15 10:30:00
slug: my-first-post
status: publish
excerpt: "A short summary of the post."
---

The converted post content follows here as Markdown.

The Markdown itself is converted from the same rendered content WordPress would show on its frontend (shortcodes and blocks resolved) — the same basis the REST API's content.rendered field uses elsewhere in this tutorial.

Image handling, in detail

With image localization enabled, only images hosted on your own WordPress domain are downloaded — an image embedded from a foreign domain (a CDN, an external embed) is left untouched, since it isn't "yours" to copy. Local images are saved into the ZIP under the same path structure they have on your site (wp-content/uploads/...), and their src in the Markdown is rewritten to a relative path pointing there.

Not a replacement for the static site

Worth repeating, since it's easy to conflate the two: this export doesn't go through your HTML template, doesn't apply markers, Change-URL rules, or Tidy HTML rules, and produces no deployable website. It's a one-off content export for feeding other tools — not an alternative output format of the static-site pipeline from Sections 3–11.

13. Troubleshooting

Data injection

  • No such property media->source_url (or similar) in the generated output → a marker rule references a field or a linked endpoint (|endpoint|field) that doesn't exist for that post. Check the exact spelling of the path against the post's actual REST data (Section 4), and that the related object (e.g. a featured image) is actually set.
  • Categories/tags render as one run-together word → the documented limitation from Section 4: multi-value fields (categories|categories|name) concatenate without a separator.
  • A template change doesn't seem to apply → the per-page Template for this page selection (Section 5) only takes effect the next time the post is saved — a Deploy click alone doesn't trigger it.

Navigation

  • A footer menu column loses its first item → the multi-column footer gotcha from Section 6: the parent/heading template needs its own <a> tag.

URL & Asset Mapping

  • A rule doesn't seem to do anything → most commonly caused by an unescaped / inside the pattern. /wp-content/uploads/ is invalid (the middle slash is read as the closing delimiter); it must be /\/wp-content\/uploads\//. An invalid pattern is silently skipped, not reported as an error.
  • The pattern looks right but still doesn't match → rules only apply to URLs that still point at your own WordPress domain at the time they run. If an earlier rule (or the automatic domain-stripping) has already turned the URL into something your pattern doesn't expect, adjust the pattern to match the URL's state after that earlier step, not the original WordPress URL.
  • Rule: /\/wp-content\/uploads\// => /media/ Result: /wp-content/uploads/2026/09/logo.jpg → /media/2026/09/logo.jpg. If your build folder shows the image under a different path than expected, double-check the rule matches this exactly, including trailing slashes.
  • An image 404s after deployment despite a correct-looking rule → confirm it's actually an <img> tag (src or srcset); links to files via <a href> have their URL text rewritten but the file itself is not copied (Section 8).

HTML Tidy Rules

  • A rule doesn't change anything → check the CSS selector actually matches an element on the page (e.g. img.wp-image-2662 requires that exact class to be present — a typo like wp-Image-2662 won't match anything, since class names are case-sensitive).
  • Rule: img|remove,loading Result: every <img loading="lazy" ...> becomes <img ...> with no loading attribute at all. If the attribute is still there, verify the operation is spelled exactly remove and the property name matches (loading, not Loading or lazy).
  • A rule seems to apply to the wrong elements → CSS selectors here work exactly like CSS selectors anywhere else; a bare img matches every <img> on the page, not just the one you had in mind. Narrow it with a class or a more specific selector, e.g. img.wp-image-2662 instead of img.
  • Only part of the rule seems to work → the syntax is strictly selector|operation,property,value (comma-separated after the |, no spaces required but tolerated around commas). An extra or missing comma changes what gets parsed as the value.

Class Mapping

  • A class doesn't show up in the class browser → it only lists classes matching one of your configured Remove CSS class prefixes. If the prefix field is empty, or the class doesn't start with any listed prefix, it won't appear there — add the prefix first, or add the mapping rule manually.
  • A class you mapped still gets removed → check for a typo in the left-hand side of the class_map rule; it must match the class name exactly (case-sensitive). An unmatched left side means the prefix-removal rule below it is the one that applies instead.
  • A mapped class's new name doesn't show any styling → mapping only renames the class in the output HTML; it doesn't generate any CSS for that new name. You still need to add matching CSS rules for it yourself (in your template or your uploaded assets, Section 3).

Deployment

  • curl_close(): Passing null... deprecation notices on PHP 8.5 → already patched in current plugin versions; make sure you're not running a template/plugin version from before that fix.
  • All pages overwrite each other under Plain permalinks → also already fixed via a fallback to {slug}.{id}.html; update to a current version if you still see this.
  • A Netlify form never shows up in the Forms dashboard → check that the redirect URL isn't pointing at your own WordPress domain (Section 7).
↑