Content2HTML

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

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

Content2HTML – WordPress Content. Your HTML.

WordPress manages your content. You control the HTML.

This tutorial covers every feature of the plugin (currently in review), 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 Content2HTML Works

Content2HTML 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

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

Content2HTML 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

Content2HTML:

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

Content2HTML 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 Content2HTML 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 Content2HTML 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.
  • Content2HTML 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 Content2HTML plugin, installed and activated like any other WordPress plugin
  • Recommended: add a WPSTATIC_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('WPSTATIC_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 → Content2HTML → 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, Content2HTML 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. Content2HTML 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 → Content2HTML → Deployment target (see Section 11 for details on Netlify vs. SFTP), then click Deploy all.

Content2HTML 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

Content2HTML 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. Content2HTML's settings screen doesn't include a built-in field browser, but 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 you the exact field names and structure available for that post, in the same format the plugin reads from. That's often the fastest way to find the path you need. If you'd rather not read raw JSON, the Content2HTML Playground has a built-in field helper — paste in a REST API URL for a single post or page, and it lists the available fields directly in path->to->field notation, ready to copy into a data injection rule.

Note

Markers are just text — nothing more. ###content### is a convention used throughout this tutorial, not a required syntax. The plugin does a plain string search-and-replace: any character sequence works as a marker, as long as it (a) doesn't otherwise occur in your template, and (b) matches exactly between your template and your data injection rules. {{content}}, %%CONTENT%%, __CONTENT__, even a single unlikely word like pups — all equally valid.

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.

Extend the rule with two extra segments separated by |:

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

Content2HTML 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 separatorTechDesignMarketing 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 → Content2HTML → 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 Content2HTML 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 Content2HTML 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### -->

Content2HTML 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 → Content2HTML → 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.)

Content2HTML 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 — Content2HTML 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, Content2HTML 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 Content2HTML 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 → Content2HTML → 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 Content2HTML 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. Content2HTML 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. Content2HTML 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

Content2HTML 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 → Content2HTML → 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 Content2HTML, 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 Content2HTML 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 Content2HTML-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, Content2HTML 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), Content2HTML 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 → Content2HTML → Tidy HTML rules, one rule per line:

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

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

Rule 2img.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).

A simpler option for one common case

For the frequent case of just wanting WordPress's own generated CSS classes gone entirely (like wp-image-2662, wp-block-*, etc.), there's a dedicated checkbox — Remove WP CSS classes, on the same tab — instead of writing custom rules for it.

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 → Content2HTML → 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 Content2HTML 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, Content2HTML 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 → Content2HTML → 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.

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