Skip to content

Repository files navigation

StaticForge for Cloudflare Pages

Auto-export your WordPress site as static HTML and deploy to Cloudflare Pages on every publish/update.

WordPress PHP Cloudflare Pages License Version Author Support on Ko-fi

A WordPress plugin that turns your install into a headless dashboard: editors keep using WordPress, but public visitors hit a static site living on Cloudflare's edge — fast, free, indestructible.


✨ Features

Feature What it means
🌐 Whole-site export Homepage, posts, pages, custom post types, taxonomy archives, author archives — all rendered to static HTML on every publish/update.
🌍 TranslatePress multilingual export Auto-detects TranslatePress and adds every secondary-language URL to the export, so each language renders to static HTML in the deploy. Translations live in the WP database but are rendered server-side, so the frozen HTML is already fully translated. Works with subdirectory mode (/fr/, /de/) — the only multilingual layout one Cloudflare Pages project can serve; subdomain/separate-domain URLs are skipped (and logged) since one deploy serves one host. Opt out via sforge_translatepress_export.
🎨 Inlined CSS Every linked stylesheet is fetched and embedded inline. Each deployed page is self-contained — no external CSS round-trips.
Featured image fetchpriority Auto-marks the post's featured image with fetchpriority="high", loading="eager", decoding="async" so the browser prioritises the LCP candidate. Improves Core Web Vitals. Works with any theme using the_post_thumbnail().
🧠 Built-in SEO metadata injection Auto-emits <meta description>, robots, canonical, full Open Graph (og:type, og:title, og:description, og:url, og:image, article:*, profile:*), Twitter Card, and rich JSON-LD schemas — all from native WP data. Auto-disables when Yoast, Rank Math, AIO SEO, SEOPress, or The SEO Framework is detected, to prevent duplicate tags.
📜 Rich JSON-LD schemas WebSite + SearchAction, Organization, Article (with author Person + publisher), WebPage, BreadcrumbList, CollectionPage for taxonomy archives, Person + ProfilePage schema for author archives (with avatar, bio, sameAs social links), auto-generated FAQPage from FAQ blocks or <details><summary>, auto-generated HowTo from HowTo blocks or "How to..." titles with ordered lists.
🗺️ Sitemap mirroring + fallback generation Auto-discovers /sitemap.xml, /sitemap_index.xml, /wp-sitemap.xml, follows children, handles CDATA-wrapped <loc> entries, rewrites origin URLs to your live domain, and bundles all child sitemaps in the deploy. If origin has none, generates a valid <urlset> sitemap.xml from the crawled URL list (with <lastmod>, <changefreq>, <priority>) so the live site always ships a sitemap.
📝 Editable robots.txt with auto-managed Sitemap line In-admin textarea. Leave blank to auto-generate, or paste your own Allow: / Disallow: rules. The Sitemap: line is always auto-managed — any directive you type is stripped and replaced with the URL that points to the actual deployed sitemap path (sitemap.xml, sitemap_index.xml, wp-sitemap.xml, etc.) so robots.txt never points at a dead URL.
🚧 Dashboard auto-noindex (social-aware) On activation, blocks the WordPress install from search engines via 4-layer enforcement: physical Disallow: / robots.txt at webroot (existing file backed up), wp_robots filter adding noindex,nofollow meta, X-Robots-Tag HTTP header on every response, and a robots_txt filter for the dynamic fallback. Social/messaging scrapers (Facebook, LinkedIn, Twitter/X, Pinterest, WhatsApp, Slack, Discord, Telegram, Apple, Reddit, Tumblr, Mastodon, Bluesky, iframely, Embedly) are explicitly allowed /wp-content/uploads/ so og:image previews still render when shared. Plugin's own export fetches are exempt — deployed pages remain fully indexable. Auto-restores backup robots.txt on plugin deactivation.
🛡️ noindex stripping Defensive removal of noindex / nofollow / noarchive directives from rendered HTML — your live site stays indexable even if the dashboard is locked down.
⏱️ Debounced auto-deploy Rapid edit clusters collapse into one deploy. Configurable 10s–3600s.
☁️ Cloudflare Direct Upload Content-addressable upload API: only changed assets re-uploaded. No Git integration needed. No build minutes consumed.
📊 Live progress UI Activity log auto-refreshes every 4 seconds with batch-by-batch telemetry, render percentages, and a status pill (Idle / Queued / Working).
📚 Built-in Setup Guide Full colour-coded walk-through inside WP admin — no docs hunting.

🧱 How it works

   WordPress (your dashboard)                    Cloudflare (your live site)
   ┌────────────────────────────────┐            ┌──────────────────────┐
   │  Editor publishes/updates post │            │                      │
   │  ↓                             │            │   Cloudflare Pages   │
   │  on:transition_post_status     │            │   ┌──────────────┐   │
   │  ↓                             │            │   │ /            │   │
   │  wp_schedule_single_event      │            │   │ /post-slug/  │   │
   │  (debounce 120s)               │            │   │ /category/.. │   │
   │  ↓                             │            │   │ /author/..   │   │
   │  Crawl URL list                │            │   │ /sitemap.xml │   │
   │  ↓                             │            │   │ /robots.txt  │   │
   │  Render via wp_remote_get      │  Direct    │   └──────────────┘   │
   │  ↓ (inject SEO + schemas)      │  Upload    │                      │
   │  ↓ (inline CSS, rewrite URLs)  │  API       │                      │
   │  ↓ (strip noindex)             │            │                      │
   │  Mirror sitemap & robots.txt   │            │   ① upload-token     │
   │  ↓                             │ ─────────► │   ② check-missing    │
   │  Hash each file (SHA-256)      │            │   ③ assets/upload    │
   │  ↓                             │            │   ④ deployments      │
   │  POST manifest + branch        │            │   (multipart)        │
   └────────────────────────────────┘            └──────────────────────┘

📦 Installation

From source

cd wp-content/plugins
git clone https://github.com/gunjanjaswal/staticforge-for-cloudflare-pages.git

Or via WP Admin

  1. Download/clone, zip the staticforge-for-cloudflare-pages/ folder.
  2. WP Admin → Plugins → Add New → Upload Plugin → choose zip.
  3. Activate.

🚀 Quick Start

Open the in-plugin Setup Guide (StaticForge for Cloudflare Pages → Setup Guide) for a richer walk-through with colour-coded sections. Six steps below are the executive summary.

1️⃣ Create a Cloudflare Pages project (Direct Upload)

  • dash.cloudflare.comWorkers & PagesCreate applicationPages tab → Upload assets.
  • Project name: lowercase slug (e.g. mysite). Becomes https://mysite.pages.dev.
  • Drag-drop a placeholder index.html to seed it. Plugin overwrites on first real deploy.

2️⃣ Create an API Token

  • Top-right avatar → My Profile → API TokensCreate Token → custom token.
  • Permission: Account → Cloudflare Pages → Edit.
  • Account Resources: include your account.
  • Copy the token — shown once.

3️⃣ Find your Account ID

  • CF Dashboard → Workers & Pages overview → right sidebar → Account ID (32-char hex).

4️⃣ Configure the plugin

Field Value
Account ID from step 3
API Token from step 2
Pages Project slug only (mysite), NOT mysite.pages.dev
Branch main for production
Public Site URL https://mysite.pages.dev (testing) → https://example.com (after DNS cutover)
Post Types post, page, your CPTs
Inline CSS
Inject SEO meta ☑ (auto-disables if SEO plugin detected)
Auto-deploy
Debounce 120

5️⃣ First deploy

Click Test Connection → expect ✅ → click Rebuild + Deploy Now → watch live log.

Final entry:

Deploy OK [<deployment-id>]  https://<id>.<project>.pages.dev

6️⃣ DNS cutover (optional, when verified)

@      CNAME  <project>.pages.dev   (proxied)
www    CNAME  <project>.pages.dev   (proxied)
dashboard A   <origin-ip>            (proxied; CF terminates SSL)

Then update the plugin's Public Site URL to your final domain → SaveRebuild + Deploy Now so canonicals + sitemap entries point to the live host.


🧠 SEO injection details

When no third-party SEO plugin is detected, StaticForge for Cloudflare Pages automatically emits a complete SEO baseline into every page's <head>:

Standard meta + social

  • <meta name="description"> — derived from post_excerpt → trimmed post_content → bio for authors → term description for taxonomies → site tagline as fallback
  • <meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1">
  • <link rel="canonical">
  • Open Graph: og:type, og:title, og:description, og:url, og:site_name, og:locale, og:image (with width/height/alt), article:published_time, article:modified_time, article:author, article:section, article:tag, profile:first_name, profile:last_name, profile:username
  • Twitter Card: summary_large_image when image available, twitter:title, twitter:description, twitter:image, twitter:creator

JSON-LD schemas

Page type Schemas emitted
Homepage / Front page WebSite + SearchAction, Organization
Single post Article (with author Person, publisher Organization, image, dates, articleSection, keywords), BreadcrumbList, FAQPage (auto), HowTo (auto)
Static page / Custom post type WebPage (with primaryImageOfPage, dates), BreadcrumbList, FAQPage (auto), HowTo (auto)
Author archive Person (display name, URL, bio, avatar ImageObject 256×256, sameAs social links from user_url + twitter/facebook/linkedin/instagram/youtube/github user meta) + ProfilePage (linked via mainEntity to the Person), BreadcrumbList
Taxonomy / Category / Tag archive CollectionPage, BreadcrumbList

Auto-detected FAQ + HowTo

  • FAQPage is emitted when the post contains:
    • a Yoast FAQ block (yoast/faq-block),
    • a Rank Math FAQ block (rank-math/faq-block),
    • a SEOPress FAQ block (seopress/faq-block),
    • OR native HTML5 <details><summary>Question</summary>Answer</details> blocks anywhere in the content.
  • HowTo is emitted when the post contains:
    • a Yoast HowTo block (yoast/how-to-block),
    • a Rank Math HowTo block (rank-math/howto-block),
    • OR the title starts with "How to" / "How To" + the post has a <ol> with 3+ list items.

Filter sforge_faq_items / sforge_howto_data to override the auto-detection results.

Auto-disable safety (two-tier dedup)

We split detection into general SEO plugins (handle everything: meta + og + schema) and schema-only plugins (handle only JSON-LD).

Tier 1 — General SEO plugin detected → ALL our injection paused (would duplicate everything):

  • Yoast SEO (free + Premium, new + legacy)
  • Rank Math (multiple class checks)
  • All in One SEO Pack (v4+ and legacy)
  • SEOPress
  • The SEO Framework
  • Slim SEO
  • Squirrly SEO
  • SmartCrawl
  • WP Meta SEO

Tier 2 — Schema-only plugin detected → only our JSON-LD paused, meta+og+twitter still emit:

  • Schema & Structured Data for WP & AMP (saswp, by Magazine3)
  • Schema Pro (by Brainstorm Force)
  • WPSSO Core
  • Schema (by Hesham)
  • Schema App
  • Magazine3 Schema variants

Override via the "Force full injection anyway" checkbox in settings — useful if you want only some of our tags but understand you may need to disable parts of the other plugin to avoid duplicates.

Extend detection via filters: sforge_seo_competing_plugin (general SEO) or sforge_schema_competing_plugin (schema-only).


🗺️ Sitemap generator (controls)

The plugin handles sitemaps two ways and you have full control:

When origin already has a sitemap

If sitemap.xml, sitemap_index.xml, or wp-sitemap.xml is reachable on your origin, the plugin mirrors it: fetches each XML file (including child sitemaps from sitemap-index entries, with CDATA-wrapped <loc> support), rewrites WP origin URLs to your Public Site URL, and bundles all of them in the deploy at their original paths.

The mirror also scrubs leftover dashboard references:

  • <?xml-stylesheet ... ?> directives are removed (Yoast/Rank Math typically point them at /wp-content/plugins/.../*.xsl on the origin — search engines ignore XSL anyway, and stripping it stops the dashboard host from appearing in browser-rendered sitemaps).
  • Both https://dashboard.example.com and protocol-relative //dashboard.example.com host references inside the XML are rewritten to your live host.

Activity log entry: Sitemap mirrored from origin: sitemap.xml, sitemap-pages.xml, ...

When origin has no sitemap

The plugin generates sitemap.xml from your settings — independently of Export Scope, so you can list URLs in the sitemap that aren't exported and vice versa. Configure at StaticForge for Cloudflare Pages → Settings → Sitemap Generator:

Setting Effect
Post Types Multi-checkbox per public post type. Default post + page. All published items + the post type archive URL get listed.
Include Homepage Adds home_url('/') and the posts page (if separate) with priority 1.0.
Include Taxonomy archives All public taxonomies' term archives — categories, tags, custom taxonomies.
Include Author archives Authors with at least one published post.
Split into multiple files When ON, sitemap.xml becomes a <sitemapindex> referencing per-type sub-sitemaps (sitemap-post.xml, sitemap-page.xml, sitemap-authors.xml, sitemap-taxonomy-category.xml, etc.). Cleaner for large sites.

Each <url> entry carries <loc> (rewritten to public URL), <lastmod> (resolved via get_post_modified_time for permalinks), <changefreq>weekly</changefreq>, <priority> (1.0 home / 0.7 other).

Activity log entries:

  • Sitemap generated locally (single mode): 449 URLs across 4 groups, 1 file(s).
  • Sitemap generated locally (split mode): 449 URLs across 4 groups, 5 file(s).
  • Sitemap fallback skipped: no post types / archives selected in Sitemap Generator settings. (warning if you've unticked everything)

Filter

sforge_sitemap_groups exposes the grouped URL list:

add_filter( 'sforge_sitemap_groups', function ( $groups ) {
    $groups['custom'][] = [ 'url' => 'https://example.com/landing/', 'lastmod' => '' ];
    return $groups;
} );

🌍 Multilingual (TranslatePress)

TranslatePress keeps translations in the WordPress database and renders them server-side per request. Because StaticForge captures each page through wp_remote_get, the frozen HTML is already fully translated — the database is never needed on the live static site. The only gap is URL discovery: the crawler enumerates default-language permalinks only.

StaticForge closes that gap automatically. When TranslatePress is active, it expands the export URL list with every secondary-language URL TranslatePress would serve (using TP's own URL converter, so the permalink mode is honoured):

  • Subdirectory mode (example.com/fr/, example.com/de/) — fully supported. Every language renders to static HTML and ships in the one deploy. Language-switcher links and hreflang tags already present in the rendered HTML resolve to the now-existing static paths.
  • Subdomain / separate-domain mode (fr.example.com) — skipped on purpose. A single Cloudflare Pages project serves one hostname, so per-subdomain content can't come from one deploy. StaticForge logs a notice telling you to switch TranslatePress to subdirectory mode. (Alternatively, run a separate export + separate Pages project per subdomain.)

No configuration needed — it activates when TranslatePress is detected. To turn it off:

add_filter( 'sforge_translatepress_export', '__return_false' );

Activity-log entry: TranslatePress: added 1347 secondary-language URL(s) across 3 language(s) to the export.

Note on forms: static pages have no PHP/WordPress runtime, so contact forms (Contact Form 7, WPForms, Gravity Forms) won't submit on the deployed site — their AJAX/REST endpoint doesn't exist on Cloudflare Pages. Point forms at a static-friendly backend (Cloudflare Pages Function / Worker, Formspree, Basin, Web3Forms). StaticForge only rewrites your exact WordPress host, so a form posting to a different hostname is left untouched and keeps working.


🔌 Clean /wp-content/ URLs (advanced)

By default the plugin keeps <origin>/wp-content/... URLs (theme CSS/JS, plugin assets, uploads) pointing at your WordPress origin so those assets keep working without bundling multi-gigabyte folders in every deploy. That means structured data such as og:image, JSON-LD image / logo / thumbnailUrl, and HTML <img src> / srcset will still reference your dashboard host on the deployed site.

If you want fully clean URLs on the live site:

  1. Set up a proxy that forwards /wp-content/* requests from your live host to the dashboard.
  2. Toggle Export Scope → Rewrite /wp-content/ URLs in the plugin settings.

Option A — Cloudflare Worker (recommended)

Cloudflare Dashboard → Workers & Pages → Create application → Workers → Hello World template → deploy → Edit code:

export default {
  async fetch(request) {
    const url = new URL(request.url);
    if (url.pathname.startsWith('/wp-content/')) {
      url.hostname = 'dashboard.example.com';
      return fetch(url, request);
    }
    return fetch(request);
  }
};

Replace dashboard.example.com with your actual WordPress host.

Worker → Settings → Triggers → Add route:

example.com/wp-content/*
www.example.com/wp-content/*

Tick Rewrite /wp-content/ URLs in plugin settings → Rebuild + Deploy Now.

Option B — Nginx / Apache reverse proxy

# Nginx — on the live host
location /wp-content/ {
    proxy_pass         https://dashboard.example.com;
    proxy_set_header   Host dashboard.example.com;
    proxy_ssl_server_name on;
}
# Apache — on the live host
<Location "/wp-content/">
    ProxyPass         "https://dashboard.example.com/wp-content/"
    ProxyPassReverse  "https://dashboard.example.com/wp-content/"
</Location>

Verify before flipping the toggle

curl -I https://example.com/wp-content/uploads/some-file.jpg
# Expect HTTP/2 200 with image content-type

If that returns 200, tick the toggle, redeploy, view source of any post — og:image, JSON-LD image/logo/thumbnailUrl, and every <img src> will reference your live host instead of the dashboard.

If the toggle is on but no proxy is set up: every image / theme stylesheet / plugin script on the live site will 404. Run the curl test first.


📜 robots.txt for the live site

The plugin ships its own robots.txt to your Cloudflare Pages site root, independent of your dashboard's robots.txt (which can stay locked down):

  • Auto-generated default: User-agent: * / Allow: / / Sitemap: https://example.com/<active-sitemap-path>
  • Editable: in-admin textarea on the settings page. Leave blank for default, or paste your own Allow: / Disallow: rules.
  • Auto-managed Sitemap line: any Sitemap: directive you type into the textarea is stripped and replaced with the URL pointing at the actually-deployed sitemap path. So if Yoast emits /sitemap_index.xml, your robots.txt always says Sitemap: https://example.com/sitemap_index.xml even if you typed /sitemap.xml. Guarantees the link is never broken.
# Your custom rules — preserved verbatim
User-agent: *
Disallow: /preview/
Disallow: /staging/

User-agent: GPTBot
Disallow: /

# Plugin auto-appends the correct one based on the deployed primary sitemap:
Sitemap: https://example.com/sitemap_index.xml

🗺️ Sitemap mirroring

On every deploy, the plugin:

  1. Tries /sitemap.xml, /sitemap_index.xml, /wp-sitemap.xml against your origin.
  2. For each that returns valid XML, parses <loc> entries — including those wrapped in <![CDATA[...]]>.
  3. Follows sitemap-index references → fetches every child sitemap.
  4. Rewrites origin URLs inside the XML payloads to your Public Site URL.
  5. Bundles them all in the deploy at their original paths.

Search engines crawling your live site see correctly-formed canonical URLs.


⚙️ Configuration reference

Setting Default Notes
Account ID 32-char hex from Cloudflare.
API Token Permission: Account · Cloudflare Pages · Edit.
Pages Project Slug, not the URL.
Branch main main = production, anything else = preview.
Public Site URL Used for URL rewriting in HTML/XML.
Post Types post, page All public types selectable.
Include Homepage
Include Taxonomies All public taxonomies + their term archives.
Include Authors Authors with at least one published post.
Inline CSS Embeds external <link> stylesheets as <style>.
Inject SEO meta Auto-disabled when an SEO plugin is detected.
Force SEO injection Force-emit even with SEO plugin active.
Auto-deploy On publish/update of selected types.
Debounce 120 sec Min 10, max 3600.
Export Folder sforge-export Inside wp-content/uploads/.
robots.txt auto Custom textarea; Sitemap: auto-appended.

🪝 Filters

Filter Purpose
sforge_url_list Modify the list of URLs to export. Receives an array of absolute URLs.
sforge_translatepress_export Return false to disable the built-in TranslatePress language-URL expansion.
sforge_sitemap_candidates Modify the sitemap discovery URL list.
sforge_seo_inject Final say on whether our SEO tags emit on a given request.
sforge_seo_inject_schema Final say on whether our JSON-LD block emits (meta+og still emit independently).
sforge_seo_competing_plugin Add custom detection for general SEO plugins → skips ALL our injection.
sforge_schema_competing_plugin Add custom detection for schema-only plugins → skips only our JSON-LD.
sforge_faq_items Override the auto-detected FAQ items ([ ['question'=>'…','answer'=>'…'], … ]).
sforge_howto_data Override the auto-detected HowTo data (['name'=>'…','steps'=>[ ['name'=>'…','text'=>'…'], … ]]).
sforge_sslverify Disable SSL verification for self-fetch (__return_false). Useful if origin cert is invalid during migration.
add_filter( 'sforge_url_list', function ( $urls ) {
    $urls[] = home_url( '/landing/' );
    return $urls;
} );

// Treat your own bespoke SEO plugin as a competitor to skip our injection on its pages.
add_filter( 'sforge_seo_competing_plugin', function ( $detected ) {
    return $detected || class_exists( 'My_Custom_SEO' );
} );

🛠 Troubleshooting

Grouped by where it happens. The code-styled phrases are the exact Activity Log messages, so you can match what you see to the fix.

Login & DNS cutover

After DNS cutover, wp-admin bounces to the live site / can't log in / can't redeploy

Symptom: clicking into wp-admin lands you on the public host's login, e.g. https://example.com/wp-login.php?redirect_to=https%3A%2F%2Fdashboard.example.com%2Fwp-admin%2F... — but example.com is now the static Cloudflare site with no WordPress on it, so login fails and you can't reach the plugin to redeploy.

Cause: WordPress's own WP Address (siteurl) / Site Address (home) still point at the public host (example.com) instead of your dashboard host (dashboard.example.com). WordPress builds the login URL from siteurl, so it sends you to the static site.

Fix: pin both to the dashboard host in wp-config.php (add these just above /* That's all, stop editing! Happy publishing. */):

define( 'WP_HOME',    'https://dashboard.example.com' );
define( 'WP_SITEURL', 'https://dashboard.example.com' );

Save, then log in from a private/incognito window (so stale cookies don't fight you) at https://dashboard.example.com/wp-admin.

This split is correct and required: WordPress lives on dashboard.example.com; the plugin's Public Site URL stays https://example.com. The renderer rewrites the dashboard host → Public Site URL in the export, so the static site still shows clean example.com links. Leave the plugin's Public Site URL as the public host — only WordPress's own two addresses move to the dashboard subdomain.

Setup & connection (Test Connection)

Test FAIL: Account ID, API token and project name are required

One of the three credential fields is blank. Fill Account ID, API Token, and Pages Project, Save, then test again.

Test FAIL: Project not found

You typed the full name.pages.dev URL into the Pages Project field. Use the slug only (e.g. mysite). Also confirm the project lives in the same Cloudflare account whose Account ID you pasted.

Test FAIL with an authentication / HTTP 403 message

The API token is wrong, expired, or under-scoped. Create one with exactly Account · Cloudflare Pages · Edit and Account Resources including the right account, re-paste it (shown only once at creation), and Save.

Upload token request failed: ...

The connection can test OK with a read-only token, but deploying needs write access. Recreate the token with Cloudflare Pages · Edit (not Read).

Rebuild won't start or won't finish

"Rebuild + Deploy Now" or auto-deploy does nothing — no new log lines

The rebuild runs on a WordPress scheduled event a few seconds after you click, so it depends on WP-Cron. If DISABLE_WP_CRON is defined, or the site gets almost no traffic, the event may never fire — you'll see Full rebuild queued (manual). but never Full rebuild started. Load any front-end page to nudge WP-Cron, or run a real system cron hitting wp-cron.php every minute.

No URLs to export. Check post type / scope settings.

No post types are ticked under Export Scope, or nothing is published in the selected types. Tick at least one post type (and/or Homepage / Taxonomies / Authors) and confirm you have published content.

Render fail <url>: HTTP 401 / 403 / 5xx

The plugin fetches your own URLs via wp_remote_get. A handful of failures is harmless; many means the site is blocking itself — HTTP basic auth, an IP allow-list, an aggressive WAF, Cloudflare "Under Attack" mode, or a coming-soon / maintenance plugin. Let the origin fetch itself (or pause the blocker during deploys). For an invalid / self-signed origin cert during migration, add add_filter( 'sforge_sslverify', '__return_false' );.

Nothing rendered, deploy skipped.

Every page failed to render, so there was nothing to upload — almost always the same self-fetch block as above. Check the Render fail lines just above it for the HTTP code.

Stuck on Manifest: ... files / Hashing files...

Upload step ran out of PHP memory or hit max execution time. Plugin already requests set_time_limit(0) and 512 MB but shared hosts may override. Raise php.ini memory_limit (256–512 MB) and max_execution_time. Plugin uses 100-file / 25 MiB batches by default.

Deploy step errors (Cloudflare API)

Deployment failed: Request body is incorrect

Older plugin builds sent the deployment POST as URL-encoded form. v1.0.0+ sends multipart/form-data which Cloudflare requires. Update the plugin.

Asset upload failed: ...

Usually a single file over Cloudflare Pages' 25 MiB per-file limit (a large video / PDF in uploads), or a network timeout on a big batch. Remove or relocate oversized media and host it elsewhere.

Deployment failed: ... (other messages)

A Cloudflare-side rejection — the exact reason is quoted in the log. Common cause: more than 20,000 files in one deployment (CF Pages free-tier limit). Trim Export Scope, or split a very large site.

check-missing failed: ...

A transient Cloudflare API hiccup or token problem mid-deploy. Re-run Rebuild + Deploy Now; if it persists, re-test the connection (the token may have been revoked).

Live site looks wrong

Images broken on deployed site (or only some show)

Plugin keeps /wp-content/* URLs pointing at your origin. Ensure the origin's SSL cert is valid — or proxy that subdomain through Cloudflare so CF terminates a fresh edge cert. If your host blocks Cloudflare (520/522 on /wp-content/uploads/*), tick Bundle /wp-content/uploads/ into deploy instead so images ship inside the static deploy.

If only some images are broken right after a DNS cutover, it's usually a stale deploy combined with the wrong Site Address (see Login & DNS cutover above) — fix that, then Rebuild + Deploy Now.

<project>.pages.dev doesn't redirect to my domain

The redirect is a client-side JS snippet (the Direct Upload API can't run _worker.js / Functions), so curl -I won't show it — test in a real browser. It only fires when Public Site URL is a real domain (not a .pages.dev URL) and the Redirect *.pages.dev to live host toggle is on.

Live site shows noindex

Don't tick Settings → Reading → "Discourage search engines" on the dashboard. It causes SEO plugins to also flag the sitemap. Plugin defensively strips noindex meta during export, but turn the toggle off to be safe.

Contact forms don't send on the live site

Cloudflare Pages is static — no PHP / WordPress runtime — so anything posting to admin-ajax.php or /wp-json/ (Contact Form 7, WPForms, Gravity Forms) silently fails. Point the form at a static-friendly endpoint (a Cloudflare Pages Function / Worker, Formspree, Basin, Web3Forms). A form posting to a different host is left untouched and keeps working.

Duplicate SEO meta or schema in HTML

Means an SEO plugin we don't auto-detect is also injecting tags. Either: (a) untick Inject SEO meta in settings, or (b) extend detection via the sforge_seo_competing_plugin / sforge_schema_competing_plugin filter.

FAQ / HowTo schema not appearing

FAQ schema needs Yoast / Rank Math / SEOPress FAQ blocks or <details><summary>Question</summary>Answer</details> markup in the content. HowTo needs a Yoast or Rank Math HowTo block, or a title starting with "How to" + a numbered list with 3+ items. Override with the sforge_faq_items / sforge_howto_data filters.

Sitemaps & multilingual

Sub-sitemaps missing on deploy

v1.0.0+ handles CDATA-wrapped <loc> URLs inside sitemap-index files. Earlier builds skipped them. Update + redeploy.

Sitemap fallback skipped: no post types / archives selected

Your origin exposes no sitemap, so the plugin tried to generate one — but everything is unticked under Sitemap Generator. Tick at least one post type / Homepage / Taxonomies / Authors.

Sitemap fallback returned no files.

The generator ran but matched no published URLs. Confirm you have published content in the selected sitemap post types.

TranslatePress languages aren't on the live site

If the log notes secondary languages are on separate subdomains / domains, that's expected — one Cloudflare Pages project serves one hostname. Switch TranslatePress to subdirectory mode (/fr/, /de/) and redeploy; all languages then ship in the one deploy.

Bundle uploads enabled, but no /wp-content/uploads/ references found

An image optimiser is swapping image URLs with JavaScript (e.g. EWWW Lazy Load or Easy IO), so the real URLs aren't in the rendered HTML for the bundler to find. Turn off the JS lazy-load / Easy-IO feature (keep the compression) and rebuild.

Limits & frequency

Hit ~100 deployments per day

Free tier soft cap. Raise the Debounce setting from 120 to 600+ so bulk edits collapse into fewer deploys.


🧪 Limits

Resource Limit
Files per deployment 20,000 (Cloudflare Pages free tier)
Single file size 25 MiB (Cloudflare Pages)
Deployments per day ~100 soft cap (Cloudflare Pages free tier)
Bandwidth / requests unlimited

📝 Changelog

1.8.0

  • New: forms that email you on submit. A static site can't process a POST, and the Pages Direct Upload deploy can't run Cloudflare Functions, so a contact form has always meant an external service. StaticForge now generates and deploys a small standalone Cloudflare Worker that takes the submitted fields and forwards them to your own email API. It's email-API-agnostic: choose a preset for Resend, SendGrid, Postmark or Mailgun (or wire up any endpoint under Custom) and it fills in the endpoint, the auth header, and an editable JSON body template. Tokens like {{name}} / {{email}} are filled from the submission and {{all_fields}} expands to the whole message; your API key is injected from a Worker secret and never touches the page or the browser. Drop [sforge_form] into any page for a ready-made name/email/message form. A honeypot is always on and Cloudflare Turnstile is a one-tick add. New classes SFORGE_Forms and SFORGE_Worker_Deployer; new Forms settings section with its own deploy/remove controls.
    • Deploying the handler needs the Cloudflare API token to also carry Account · Workers Scripts · Edit, plus a free workers.dev subdomain on the account (claimed once from Workers & Pages). Everything else in the plugin is unchanged and needs neither.

1.7.0

  • New: translation-ready. The admin UI, the Setup Guide, the contextual Help tabs, and the on-screen labels and descriptions are now wrapped in WordPress i18n functions against the staticforge-for-cloudflare-pages text domain, and a languages/staticforge-for-cloudflare-pages.pot template ships with the plugin. Translations can be contributed at translate.wordpress.org. Nothing changes for English installs.
  • Fix: more in-post links get rewritten to your Public Site URL. The rewriter matched only your site's exact home URL, so a link an editor pasted as http:// when the site is https://, or with a stray www. (or missing one), or as a protocol-relative //your-site/..., stayed pointing at the WordPress origin. Those near-miss spellings of your own host are now folded onto the canonical form before the rewrite, so they get swapped along with everything else. Third-party links are left alone, and /wp-content/ assets still follow the bundle / keep-on-origin rules. New SFORGE_Renderer::normalize_origin_aliases().

1.6.0

  • New: self-hosted fonts are bundled automatically. A font whose @font-face src still points at your WordPress host is a cross-origin request once the page is served from *.pages.dev. Browsers fetch fonts in CORS mode, your origin doesn't send an Access-Control-Allow-Origin header, and the font gets blocked — the console fills with "blocked by CORS policy" errors and the page drops to a system typeface. StaticForge now scans each rendered page for .woff2 / .woff / .ttf / .otf / .eot files served from your own host, ships them inside the deploy, and rewrites their URLs to your Public Site URL so they load same-origin. Covers theme webfonts, icon fonts, and Astra's local Google Fonts at wp-content/astra-local-fonts/. Third-party fonts (Google Fonts on fonts.gstatic.com, etc.) already send CORS headers and are left untouched. On by default via the new Export Scope → Bundle self-hosted fonts setting (bundle_fonts); ignored when Rewrite /wp-content/ URLs is on, since that already rewrites fonts. New SFORGE_Renderer::collect_fonts_from_html() / get_collected_fonts() and SFORGE_Rebuild::bundle_fonts(). Run one Full Rebuild after updating so every page picks it up.
  • Improved: extra paths accept a wp-content/-relative entry. If an Extra paths to include entry isn't found at the WordPress root, the plugin now looks under wp-content/ before giving up — so astra-local-fonts resolves the same as wp-content/astra-local-fonts, and both the copy and the URL rewrite act on the real files. This removes the most common reason a bundled asset kept pointing at the old domain: the rewrite hinges on the wp-content/ prefix, and a bare path used to silently miss it. New SFORGE_Extra_Assets::resolve_root_relative().

1.5.0

  • New: extra paths to include. A new Export Scope → Extra paths to include setting lets you bundle files and folders the crawler never encounters in the rendered HTML — a plugin's icon font (Elementor's Font Awesome at wp-content/plugins/elementor/assets/lib/font-awesome, say), a webfont directory, a downloadable PDF. List them one path per line relative to your WordPress root; the plugin copies them straight off local disk into the deploy. A whole folder is pulled in recursively, a single file is copied as-is, and a trailing wildcard (wp-content/uploads/2025/*.pdf) is expanded. Bundled files under /wp-content/ get their URLs pointed at the live host so the deployed page loads the bundled copy, while the rest of /wp-content/ stays on origin exactly as before. Reading locally means no origin firewall gets in the way. Paths are confined to the WordPress root — anything escaping it via .. or a symlink is skipped and logged — and each rebuild is capped at 5,000 files / 200 MB. New setting extra_paths, new class SFORGE_Extra_Assets.

1.4.0

  • New: partial rebuilds. Publishing a post used to re-render the entire site. Every page costs one HTTP round-trip to the origin, and they run one after another, so a 1,400-page site paid 1,400 sequential fetches to fix a typo — the single biggest cause of hours-long rebuilds. The plugin now re-renders only the pages an edit actually invalidates (the post itself, its term archives, its author archive, its post type archive, the homepage and posts page) plus their translations, and reuses the export cache for everything else. Editing one post typically touches a handful of URLs instead of the whole site.
  • New: the export directory is now the deploy source. Rendered pages were always written to wp-content/uploads/sforge-export/, but the deploy was built from an in-memory copy and that directory was never read back. It is now the source of truth for the manifest, which is what makes partial rebuilds possible: a Cloudflare Pages deployment is a whole-site snapshot, so the manifest must list every file even when only a few changed.
  • Improved: memory no longer scales with site size. File bodies are streamed from disk for hashing (hash_update_file) and read back only for the assets Cloudflare reports as new, so peak memory tracks the 25 MB upload batch rather than the whole site. A 1,400-page export now costs about the same RAM as a 10-page one. This addresses rebuilds that stalled at the Manifest step on memory-limited hosts.
  • New: stale pages are pruned. Deleting or unpublishing a post now removes its exported HTML, so it stops being served. Pruning is scoped to HTML and refuses to run if a partial rebuild's URL list looks implausibly short (more than a quarter of pages suddenly unaccounted for) — it logs a warning and asks for a full rebuild rather than quietly deleting most of the live site.
  • New: two rebuild buttons. Rebuild Changed + Deploy renders only what changed; Full Rebuild + Deploy re-renders everything and reconciles the export cache from scratch. Auto-deploy uses the partial path.
  • New: per-post Rebuild action. A Rebuild link on post and page list tables pushes one page live without waiting out the debounce. Useful when a change never went through the publish flow — an edited menu, a widget, a template tweak, a translation saved in place.
  • Improved: bundled uploads are cached. With Bundle /wp-content/uploads/ enabled, files already in the export directory are no longer re-fetched from the origin on every rebuild.
  • Improved: a failed render keeps the previous page. Renders used to be dropped from the deploy on failure, so a transient error could remove a live page. The last good copy now stays in the export cache and keeps being served.
  • Internals: new SFORGE_Export_Store, SFORGE_Rebuild and SFORGE_Post_Actions classes; rebuild orchestration moved out of SFORGE_Hooks; new SFORGE_Deployer::deploy_dir() and SFORGE_Deployer::hash_file(); new sforge_partial_rebuild cron hook alongside sforge_full_rebuild. Asset hashing is unchanged, so assets already cached at Cloudflare stay cached across the upgrade.

1.3.1

  • Fix: stray UTF-8 BOM in the main plugin file. A byte order mark got saved into staticforge-for-cloudflare-pages.php when 1.3.0 was cut. Those three bytes (EF BB BF) sit before the opening <?php tag, so PHP sent them to the browser as output on every request. That surfaced two ways: the Plugins screen warned "The plugin generated 3 characters of unexpected output during activation", and Site Health reported "The REST API did not process the context query parameter correctly" — the BOM was being prepended to every REST response, so the JSON no longer parsed. Update if you're on 1.3.0. Nothing else changed; the 1.3.0 render origin override is untouched.

1.3.0

  • New: Render origin override (Performance settings). The export renders each page by fetching it over HTTP from the site's own URL, sequentially. When the domain sits behind a CDN/proxy (e.g. Cloudflare), every one of those requests leaves the server and round-trips through the edge — on a large or multilingual site (hundreds/thousands of pages) that network hop dominates the rebuild time, not PHP. Set this to a host that reaches WordPress directly on the same server (usually http://127.0.0.1, or http://127.0.0.1:8080 if PHP listens on another port) and the whole crawl stays local. The plugin keeps your real domain in the Host header so WordPress still serves the correct site/language, and TLS verification is skipped for the override only (a loopback cert won't match the public host). Applies to page renders, inlined CSS, mirrored sitemaps, and bundled uploads; only URLs on the origin host are redirected — everything else is fetched unchanged. Leave blank to keep the previous behaviour. New setting render_origin, new helper SFORGE_Renderer::localize_request().

1.2.1

  • Docs: post-DNS-cutover login trap. Added a troubleshooting entry (README, in-plugin Setup Guide, and wp.org FAQ) for the most common cutover mistake: leaving WordPress's own WP Address (siteurl) / Site Address (home) on the public host after pointing it at Cloudflare Pages. WordPress then builds the login URL against the static site (https://example.com/wp-login.php?redirect_to=https://dashboard.example.com/...), so you can't log in or redeploy. Fix is to pin WP_HOME + WP_SITEURL to the dashboard host in wp-config.php while keeping the plugin's Public Site URL on the public host.
  • Docs: comprehensive troubleshooting on every surface. Reorganised Troubleshooting into grouped subsections — Login & DNS cutover, Setup & connection, Rebuild won't start or finish (incl. the WP-Cron "nothing happens" case), Deploy step errors, Live site looks wrong, Sitemaps & multilingual, Limits & frequency — with 26 entries keyed to the exact Activity Log messages (No URLs to export, Render fail … HTTP, Nothing rendered, Asset upload failed, Deployment failed, check-missing failed, Sitemap fallback …, Bundle uploads enabled, but no … references found, etc.). The same coverage now lives in all three places: this README, the wp.org readme.txt FAQ, and the in-plugin Setup Guide — so users hit it wherever they look.
  • No code change — documentation only.

1.2.0

  • New: TranslatePress multilingual export. Auto-detects an active TranslatePress install and expands the export URL list with every secondary-language URL (via TranslatePress's own URL converter, so the configured permalink mode is honoured), so each language renders to static HTML and ships in the deploy. Translations are DB-stored but rendered server-side, so the frozen HTML is already translated — no runtime DB dependency. Supports subdirectory mode (/fr/, /de/); subdomain/separate-domain language URLs are skipped (and a notice is logged) because one Cloudflare Pages project serves a single hostname. Auto-on when TranslatePress is detected; opt out with add_filter( 'sforge_translatepress_export', '__return_false' ). New class SFORGE_TranslatePress, new filter sforge_translatepress_export.

1.1.1

  • Fix: removed the plugin's own injected "View details" row-meta link to prevent a duplicate entry, since WordPress now auto-injects "View details" for wp.org-hosted plugins. Row meta is now View details | Plugin Support | Contact Developer.

1.1.0

  • Rename: plugin renamed from "Send Static to Pages" to "StaticForge for Cloudflare Pages". Folder slug, main file (staticforge-for-cloudflare-pages.php), text domain, all class/constant/function/option prefixes (SSTP_/sstp_SFORGE_/sforge_), and the sstp_full_rebuild cron hook moved over together.
  • Auto-migration: on plugins_loaded (priority 1) the plugin copies legacy sstp_settings and sstp_log to the new option keys and reschedules any pending sstp_full_rebuild cron event to sforge_full_rebuild. Guarded by a one-shot sforge_migrated_from_sstp flag so it runs at most once per install.
  • Uninstall now also deletes legacy sstp_* options and clears the sstp_full_rebuild cron hook so leftover state is fully removed.
  • WordPress 7.0 tested. Audited for deprecated APIs — all good (uses modern wp_remote_*, register_setting, etc.). No iframed-editor impact: the plugin only renders an admin settings page in the parent admin chrome.
  • WordPress 7.0 Connectors API: registers a deployment_target connector (sforge-cloudflare-pages) on the wp_connectors_init action so the plugin appears on the central Connections screen. The Cloudflare API token lives inside the sforge_settings array, which does not fit the Connectors API single-value api_key shape, so the connector is registered with method: none and credentials_url linking back to the StaticForge settings screen for credential management. Falls back silently on WP < 7.0.
  • Plugin row links: added Settings and Support on Ko-fi next to Deactivate, plus Plugin Support (WordPress.org forum) and Contact Developer row meta.
  • Donate link moved to Ko-fi: ko-fi.com/gunjanjaswal.

1.0.1

  • Social-aware dashboard noindex — robots.txt explicitly allows /wp-content/uploads/ for facebookexternalhit / Twitterbot / LinkedInBot / Pinterestbot / WhatsApp / Slackbot / Discordbot / TelegramBot / Applebot / redditbot / Tumblr / iframely / Embedly / Mastodon / Bluesky / meta-externalagent. X-Robots-Tag skipped for media paths and social-scraper user agents so og:image previews resolve.
  • *.pages.dev 301-redirect middleware — emits functions/_middleware.js into the deploy when Public Site URL is a custom domain.
  • Opt-in Rewrite /wp-content/ URLs setting + Setup Guide instructions for Cloudflare Worker / Nginx / Apache reverse proxies.
  • Standalone Person + ProfilePage JSON-LD module for author archives (sameAs auto-collected from user_meta).
  • Dashboard auto-noindex on activation — physical robots.txt at webroot (backed up to robots.txt.sforge-backup), wp_robots filter, X-Robots-Tag header. Plugin's own export fetches exempt via X-SFORGE-Export header. Restored on deactivation.
  • Fallback sitemap.xml builder when origin has none — standards-compliant <urlset> from native WP data.
  • Granular sitemap generator settings — per-post-type checkboxes, include/exclude homepage / taxonomies / authors, split-mode toggle emitting <sitemapindex>. Filter: sforge_sitemap_groups.
  • Featured-image LCP boost — fetchpriority="high", loading="eager", decoding="async".
  • Activity-log visibility for sitemap mirror-vs-generate decisions.
  • Auto-backfill new option defaults on plugins_loaded.
  • URL rewriter now also handles escaped forward slashes (https:\/\/origin\/...) for JSON-LD / REST embeds.
  • Mirrored sitemaps strip <?xml-stylesheet ... ?> directives + rewrite protocol-relative //host references.
  • Sitemap: line in custom robots.txt is auto-managed so it always points at the actually-deployed sitemap path.
  • Multipart/form-data deployment POST (was URL-encoded — Cloudflare rejected as "Request body is incorrect").
  • Fix: fatal parse error from ?> inside a // line comment terminating <?php block.
  • Fix: CDATA-wrapped <loc> entries in sitemap-index files now expand correctly.

1.0.0

  • Initial public release.
  • Cloudflare Pages Direct Upload API client (multipart/form-data deployment).
  • Whole-site crawl: posts, pages, custom post types, taxonomy and author archives, homepage.
  • CSS inlining for self-contained pages.
  • Featured image LCP boost.

🤝 Contributing

Issues & PRs welcome. Contact: hello@gunjanjaswal.me


☕ Support

If StaticForge saves you time or money, consider supporting the development on Ko-fi:

Support on Ko-fi


📄 License

GPL-2.0-or-later. Same as WordPress.


👤 Author

Gunjan Jaswalwww.gunjanjaswal.mehello@gunjanjaswal.meko-fi.com/gunjanjaswal

About

Auto-export your WordPress site as static HTML and deploy to Cloudflare Pages on every publish/update.

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages