Static Site Exporter

Plugin Banner

Static Site Exporter

by Ben Balter

Download
Description

Features

  • Converts all posts, pages, and settings from WordPress to Markdown and YAML for use in Jekyll (or Hugo or any other Markdown and YAML based site engine)
  • Export what your users see, not what the database stores (runs post content through the_content filter prior to export, allowing third-party plugins to modify the output)
  • Converts all post_content to Markdown
  • Converts all post_meta and fields within the wp_posts table to YAML front matter for parsing by Jekyll
  • Generates a _config.yml with all settings in the wp_options table
  • Outputs a single zip file with _config.yml, pages, and _posts folder containing .md files for each post in the proper Jekyll naming convention
  • Selective export: Export only specific categories, tags, or post types using WP-CLI
  • No settings. Just a single click.

Usage

  1. Place plugin in /wp-content/plugins/ folder
  2. Activate plugin in WordPress dashboard
  3. Select Export to Jekyll from the Tools menu

More information

See the full documentation:

Selective Export by Category or Tag

This feature allows you to export only a specific subset of your WordPress content, filtered by category, tag, or post type. This is particularly useful when:

  • You have a large WordPress site but only need to convert specific sections
  • You want to migrate content by topic or category
  • You need to export content incrementally

Using WP-CLI

The easiest way to perform selective exports is via WP-CLI commands.

Export by Category

To export posts from a single category, use the category slug:

`bash

wp jekyll-export –category=technology > technology-export.zip
`

To export from multiple categories (OR logic – posts in any of these categories):

`bash

wp jekyll-export –category=tech,news,updates > export.zip
`

Export by Tag

To export posts with a specific tag:

`bash

wp jekyll-export –tag=featured > featured-export.zip
`

To export posts with multiple tags (OR logic):

`bash

wp jekyll-export –tag=featured,popular > export.zip
`

Export Specific Post Types

To export only pages:

`bash

wp jekyll-export –post_type=page > pages-export.zip
`

To export only posts:

`bash

wp jekyll-export –post_type=post > posts-export.zip
`

To export custom post types:

`bash

wp jekyll-export –post_type=portfolio,testimonial > custom-export.zip
`

Combining Filters

You can combine multiple filters. Posts must match ALL specified filters (AND logic):

`bash<h3>Export posts that are in "technology" category AND have "featured" tag</h3>wp jekyll-export --category=technology --tag=featured --post_type=post > export.zip
`<h3>Using PHP Filters</h3>

For more programmatic control, you can use WordPress filters directly in your theme’s functions.php or a custom plugin.

Filter by Category

`php

add_filter( ‘jekyll_export_taxonomy_filters’, function() {
return array(
‘category’ => array( ‘technology’, ‘science’ ),
);
} );
`

Filter by Tag

`php

add_filter( ‘jekyll_export_taxonomy_filters’, function() {
return array(
‘post_tag’ => array( ‘featured’, ‘popular’ ),
);
} );
`

Filter by Custom Taxonomy

`php

add_filter( ‘jekyll_export_taxonomy_filters’, function() {
return array(
‘my_custom_taxonomy’ => array( ‘term-slug-1’, ‘term-slug-2’ ),
);
} );
`

Combine Multiple Taxonomies

`php

add_filter( ‘jekyll_export_taxonomy_filters’, function() {
return array(
‘category’ => array( ‘technology’ ),
‘post_tag’ => array( ‘featured’ ),
‘custom_tax’ => array( ‘term-1’ ),
);
} );
`

Filter Post Types

`php

add_filter( ‘jekyll_export_post_types’, function() {
return array( ‘post’, ‘page’ ); // Only export posts and pages
} );
`

Finding Category and Tag Slugs

If you’re not sure what slug to use:

Via WordPress Admin

  1. Go to Posts > Categories or Posts > Tags
  2. Hover over the category/tag name
  3. Look at the browser’s status bar or the URL – you’ll see something like tag_ID=123&taxonomy=post_tag&term_slug=featured
  4. The slug is the part after term_slug=

Via WP-CLI

List all categories with their slugs:

`bash

wp term list category –fields=name,slug
`

List all tags with their slugs:

`bash

wp term list post_tag –fields=name,slug
`

Use Cases

Scenario 1: Export a Single Blog Section

You have a WordPress site with multiple sections (Tech, Lifestyle, Travel) and want to move just the Tech section to a static site:

`bash

wp jekyll-export –category=tech > tech-blog-export.zip
`

Scenario 2: Export Featured Content

You want to export only posts marked as “featured” for a special showcase site:

`bash

wp jekyll-export –tag=featured > featured-content.zip
`

Scenario 3: Export by Year (using custom taxonomy)

If you’ve tagged posts by year, you can export by year:

`bash

wp jekyll-export –tag=2024 > 2024-posts.zip
`

Scenario 4: Migrate Content Incrementally

Export different categories separately for incremental migration:

`bash

wp jekyll-export –category=tech > tech.zip
wp jekyll-export –category=news > news.zip
wp jekyll-export –category=reviews > reviews.zip
`

Technical Details

  • Taxonomy Filtering: Uses WordPress term slugs (not names or IDs)
  • Query Performance: Filtering is done at the database level for efficiency
  • OR Logic Within Taxonomy: Multiple terms in the same taxonomy use OR logic (e.g., posts in category A OR B)
  • AND Logic Across Taxonomies: Multiple taxonomies use AND logic (e.g., posts in category A AND having tag B)
  • Post Type Filtering: Works independently of taxonomy filtering

Limitations

  • Revisions are excluded when using taxonomy filters (as they don’t have taxonomy terms)
  • Taxonomy filtering uses term slugs, not term IDs or names
  • Empty taxonomy filters are ignored (no filtering applied)

Troubleshooting

No Posts Exported

If your export is empty:

  1. Check the slug: Make sure you’re using the term slug, not the name
    • Use wp term list category to verify the exact slug
  2. Check post status: Only published, future, and draft posts are exported
  3. Verify taxonomy: Make sure you’re using the correct taxonomy name (category, post_tag, etc.)

Wrong Posts Exported

If you’re getting unexpected posts:

  1. Check term associations: Verify which posts have the category/tag assigned
  2. Review filter logic: Remember that multiple categories use OR logic
  3. Clear cache: If testing, use wp cache flush between exports

Custom post types

To export custom post types, you’ll need to add a filter (w.g. to your themes config file) to do the following:

`php

add_filter( ‘jekyll_export_post_types’, function() {
return array(‘post’, ‘page’, ‘you-custom-post-type’);
});
`

The custom post type will be exported as a Jekyll collection. You’ll need to initialize it in the resulting Jekyll site’s _config.yml.

Developing locally

Option 1: Using Dev Containers (Recommended)

The easiest way to get started is using VS Code Dev Containers or GitHub Codespaces:

  1. Install VS Code and the Dev Containers extension
  2. git clone https://github.com/benbalter/wordpress-to-jekyll-exporter
  3. Open the folder in VS Code
  4. Click “Reopen in Container” when prompted
  5. Wait for the container to build and dependencies to install
  6. Access WordPress at http://localhost:8088

The devcontainer includes:
– Pre-configured WordPress and MySQL
– All PHP extensions and Composer dependencies
– VS Code extensions for PHP development, debugging, and testing
– WordPress coding standards configured

See .devcontainer/README.md for more details.

Option 2: Manual Setup

Prerequisites

  1. sudo apt-get update
  2. sudo apt-get install composer
  3. sudo apt-get install php7.3-xml
  4. sudo apt-get install php7.3-mysql
  5. sudo apt-get install php7.3-zip
  6. sudo apt-get install php-mbstring
  7. sudo apt-get install subversion
  8. sudo apt-get install mysql-server
  9. sudo apt-get install php-pear
  10. sudo pear install PHP_CodeSniffer

Bootstrap & Setup

  1. git clone https://github.com/benbalter/wordpress-to-jekyll-exporter
  2. cd wordpress-to-jekyll-exporter
  3. script/bootstrap
  4. script/setup

Option 3: Docker Compose Only

  1. git clone https://github.com/benbalter/wordpress-to-jekyll-exporter
  2. docker-compose up
  3. open localhost:8088

Running tests

script/cibuild<h3>Custom fields</h3>

When using custom fields (e.g. with the Advanced Custom fields plugin) you might have to register a filter to convert array style configs to plain values.

Available Filters

The plugin provides two filters for customizing post metadata:

  • jekyll_export_meta: Filters the metadata for a single post before it’s merged with taxonomy terms. Receives $meta array as the only parameter.
  • jekyll_export_post_meta: Filters the complete metadata array (including taxonomy terms) just before it’s written to the YAML frontmatter. Receives $meta array and $post object as parameters. This is the recommended filter for most use cases.

Note: As of the latest version, the plugin no longer automatically removes empty or falsy values from the frontmatter. All metadata is preserved by default. If you want to remove certain fields, you can use the jekyll_export_post_meta filter to customize this behavior.

By default, the plugin saves custom fields in an array structure that is exported as:

`php

[“my-bool”]=>
array(1) {
[0] => string(1) “1”
}
[“location”]=>
array(1) {
[0] => string(88) “My address”
}
`

And this leads to a YAML structure like:

`yaml

my-bool:
– “1”
location:
– ‘My address’
`

This is likely not the structure you expect or want to work with. You can convert it using a filter:

`php

add_filter( ‘jekyll_export_meta’, function($meta) {
foreach ($meta as $key => $value) {
if (is_array($value) && count($value) === 1 && array_key_exists(0, $value)) {
$meta[$key] = $value[0];
}
}

return $meta;

});
`

A more complete solution could look like that:

`php

add_filter( ‘jekyll_export_meta’, function($meta) {
foreach ($meta as $key => $value) {
// Advanced Custom Fields
if (is_array($value) && count($value) === 1 && array_key_exists(0, $value)) {
$value = maybe_unserialize($value[0]);
// Advanced Custom Fields: NextGEN Gallery Field add-on
if (is_array($value) && count($value) === 1 && array_key_exists(0, $value)) {
$value = $value[0];
}
}
// convert types
$value = match ($key) {
// Advanced Custom Fields: “true_false” type
‘my-bool’ => (bool) $value,
default => $value
};
$meta[$key] = $value;
}

return $meta;

});
`

Removing Empty or Falsy Values

If you want to remove empty or falsy values from the frontmatter (similar to the pre-3.0.3 behavior), you can use the jekyll_export_post_meta filter:

`php

add_filter( ‘jekyll_export_post_meta’, function( $meta, $post ) {
foreach ( $meta as $key => $value ) {
// Remove falsy values except numeric 0
if ( ! is_numeric( $value ) && ! $value ) {
unset( $meta[ $key ] );
}
}
return $meta;
}, 10, 2 );
`

Command-line Usage

If you’re having trouble with your web server timing out before the export is complete, or if you just like terminal better, you may enjoy the command-line tool.

It works just like the plugin, but produces the zipfile on STDOUT:

`

php jekyll-export-cli.php > jekyll-export.zip
`

If using this method, you must run first cd into the wordpress-to-jekyll-exporter directory.

Alternatively, if you have WP-CLI installed, you can run:

`

wp jekyll-export > export.zip
`

The WP-CLI version will provide greater compatibility for alternate WordPress environments, such as when wp-content isn’t in the usual location.

Filtering by Category or Tag

You can export only specific categories or tags using the WP-CLI command. This is useful when you want to convert just one section of your WordPress site instead of the entire corpus.

Export posts from a specific category:

`bash

wp jekyll-export –category=technology > export.zip
`

Export posts from multiple categories:

`bash

wp jekyll-export –category=tech,news,updates > export.zip
`

Export posts with a specific tag:

`bash

wp jekyll-export –tag=featured > export.zip
`

Export only pages (or specific post types):

`bash

wp jekyll-export –post_type=page > export.zip
`

Combine filters:

`bash

wp jekyll-export –category=technology –tag=featured –post_type=post > export.zip
<h3>Using Filters in PHP</h3>
If you're using the plugin via PHP code or want more control, you can use the
jekyll_export_taxonomy_filters` filter:

`php

add_filter( ‘jekyll_export_taxonomy_filters’, function() {
return array(
‘category’ => array( ‘technology’, ‘science’ ),
‘post_tag’ => array( ‘featured’ ),
);
} );

// Then trigger the export
global $jekyll_export;
$jekyll_export->export();
`

Where to get help or report an issue

  • For getting started and general documentation, please browse, and feel free to contribute to the project documentation.
  • For support questions (“How do I”, “I can’t seem to”, etc.) please search and if not already answered, open a thread in the Support Forums.
  • For technical issues (e.g., to submit a bug or feature request) please search and if not already filed, open an issue on GitHub.

Things to check before reporting an issue

  • Are you using the latest version of WordPress?
  • Are you using the latest version of the plugin?
  • Does the problem occur even when you deactivate all plugins and use the default theme?
  • Have you tried deactivating and reactivating the plugin?
  • Has your issue already been reported?

What to include in an issue

  • What steps can another user take to recreate the issue?
  • What is the expected outcome of that action?
  • What is the actual outcome of that action?
  • Are there any screenshots or screencasts that may be helpful to include?
  • Only include one bug per issue. If you have discovered two bugs, please file two issues.

Performance Optimizations

This document describes the performance optimizations implemented in Static Site Exporter to improve export speed and reduce resource usage, especially for large WordPress sites.

Overview

The following optimizations have been implemented to address performance bottlenecks identified in the export process:

1. Optimized Database Queries

Problem: The original get_posts() method executed a separate SQL query for each post type, then merged the results using array_merge().

`php

// Before (inefficient)
foreach ( $post_types as $post_type ) {
$ids = $wpdb->get_col( $wpdb->prepare( “SELECT ID FROM {$wpdb->posts} WHERE post_type = %s”, $post_type ) );
$posts = array_merge( $posts, $ids );
}
`

Solution: Changed to a single SQL query using an IN clause.

`php

// After (optimized)
$placeholders = implode( ‘, ‘, array_fill( 0, count( $post_types ), ‘%s’ ) );
$query = “SELECT ID FROM {$wpdb->posts} WHERE post_type IN ($placeholders)”;
$posts = $wpdb->get_col( $wpdb->prepare( $query, $post_types ) );
`

Impact: Reduces database round trips from N (number of post types, typically 3) to 1, significantly improving performance on sites with many posts.

2. User Data Caching

Problem: The convert_meta() method called get_userdata() for every post, resulting in redundant database queries for posts by the same author (N+1 query problem).

`php

// Before (inefficient)
‘author’ => get_userdata( $post->post_author )->display_name,
`

Solution: Implemented a static cache to store user data across post conversions.

`php

// After (optimized)
static $user_cache = array();
if ( ! isset( $user_cache[ $post->post_author ] ) ) {
$user_data = get_userdata( $post->post_author );
$user_cache[ $post->post_author ] = $user_data ? $user_data->display_name : ”;
}
‘author’ => $user_cache[ $post->post_author ],
`

Impact: Eliminates redundant database queries for author information. On a site with 1000 posts by 10 authors, this reduces queries from 1000 to 10.

3. HTML to Markdown Converter Reuse

Problem: A new HtmlConverter instance was created for every post, wasting memory and CPU cycles on object initialization.

`php

// Before (inefficient)
$converter = new HtmlConverter( $converter_options );
$converter->getEnvironment()->addConverter( new TableConverter() );
`

Solution: Reuse a single static instance across all post conversions.

`php

// After (optimized)
static $converter = null;
if ( null === $converter ) {
$converter_options = apply_filters( ‘jekyll_export_markdown_converter_options’, array( ‘header_style’ => ‘atx’ ) );
$converter = new HtmlConverter( $converter_options );
$converter->getEnvironment()->addConverter( new TableConverter() );
}
`

Impact: Reduces object creation overhead. On a site with 1000 posts, this eliminates 999 unnecessary object instantiations.

4. Improved File Operations

Problem: The copy_recursive() method used the legacy dir()

Useless

By andybrandt on May 6, 2025

Markdown export is not really Markdown...

Does exactly what it says, nothing more

By andrewjgwise on January 30, 2023

Worked without issue on a WordPress site on shared hosting. Much quicker than using the Jekyll importer.

Try command line version if not working for you

By rpeyron on August 14, 2022

If you have a not so small WordPress site, it is very likely you won't have any success using this plugin within the WordPress interface, as I can read in the reviews and support messages. But the plugin offer a command line interface that does not have the timeout of the web version. Go inside the plugin folder and run the cli version :
php jekyll-export-cli.php > jekyll-export.zip
The GitHub does provide more information (WordPress forbid to include the link, but the project name seems to be benbalter/wordpress-to-jekyll-exporter) Not doing miracles with plugins, you will have some rework, but definitely working and very useful!

Working in 2022

By turbidplaque on March 17, 2022

I wasn't expecting much because of the previous reviews indicating this was broken, but having just used it on an (admittedly somewhat small) WP site, I can confirm that it now works. Even with only a couple dozen posts and some pictures, it took a few minutes, so give it time, but the result was exactly as advertised: a .zip file containing all posts as Jekyll-formatted .md files, and the WP uploads directory with all the images. Thanks!

Finally dead

By sinisam on January 8, 2020

It does not work anymore. I suppose it was good while it worked but I will never know.

Broken. doesn't work anymore

By Aslan French (thedonquixotic) on December 19, 2019

Sad because I'd really like to export my posts but doesn't work.

works still with wp 5.0.2

By andrewufrank on December 25, 2018

I had to export to Hugo and used the plugin with cpanell. upload the zip file to wp-plugins and unpack there. activate and export as instructed. takes long. result has posts in _posts folder and the rest as files; these can easily be placed in correct folders for hugo or jekyll. thank you - saves a lot of time!

Not what I expected.

By vertigoray on May 24, 2018

  1. Non of my blog articles were exported in the zip; only pics and pdfs.
  2. The zip wasn't valid; had to use 7-zip to force extraction.
Didn't give 1* cause something was exported. Not at all what I was expecting.

Works as expected

By andeersg on September 3, 2016

Very nice and simple plugin that collects all posts, pages, categories and tags into a zip file.

A big plus for also adding the images and keeping the same folder structure for them.

4.1.1

  • Fixed a zero-byte or unreadable zip download (#413). ZipArchive defers every write to close(), so a full disk, an exhausted quota, or an unwritable temp directory produced a missing archive that the exporter happily streamed as an empty response. zip_folder() now throws when close() fails, and zip() verifies the archive exists and is non-empty before it is sent
  • zip_folder() now detects a failed ZipArchive::open(). open() returns a non-zero integer error code rather than false on failure, so the previous falsy check never fired and every addFile() call silently no-op’d
  • send() opens the archive before emitting any headers and throws if it cannot be read, instead of returning silently after the download headers were already sent
  • send() disables transparent gzip compression (zlib.output_compression) so the Content-Length header cannot disagree with the bytes actually written
  • send() fails with the offending file and line number when a theme or another plugin has already written to the response (a stray blank line or byte order mark), instead of shipping a corrupt archive
  • The temporary zip now uses the same random suffix as the temporary export directory, so concurrent or previously crashed exports cannot collide on a fixed wp-jekyll.zip in a shared temp directory
  • ob_start() now wraps the jekyll_export action, so output echoed by a third-party hook can no longer be prepended to the archive
  • Skip files that vanish mid-export rather than adding a zip entry with an empty name

4.1.0

  • Behavior change: Post revisions are no longer exported by default. Previously revision was included in the default post types, which filled the _drafts/ folder with duplicate copies of every post. To restore the old behavior, re-add 'revision' via the jekyll_export_post_types filter
  • Broadened the convert_content() fallback to catch any Throwable (not just InvalidArgumentException) from the HTML-to-Markdown converter, so an unexpected converter error falls back to the post’s raw HTML instead of aborting the entire export
  • Emit a WP_DEBUG-gated warning when a public custom field shadows a reserved front matter key (e.g. layout, image, date), surfacing silent overrides. The override behavior itself is unchanged
  • Internal: de-duplicated the raw-HTML fallback filters in convert_content() and the reflection boilerplate in ColspanTableConverter

4.0.4

  • Stream the export zip to the browser in 8 KB chunks instead of loading the entire archive into memory in send(), so large exports no longer hit memory_limit after a successful build
  • zip_folder() now throws RuntimeException instead of calling wp_die() directly, so the existing export() try/catch renders a friendly error and runs cleanup() on partial temp files
  • Added jekyll_export_html_converter filter so integrations (and tests) can swap in a custom HTML-to-Markdown converter
  • Gated the v4.0.3 fallback error_log() call behind WP_DEBUG
  • Hardened sanitization of $_GET['type'] in the export callback
  • Added regression tests for the v4.0.3 Invalid HTML was provided fallback and for the new zip_folder() throw behavior

4.0.3

  • Catch InvalidArgumentException from league/html-to-markdown in convert_content() and fall back to the post’s raw HTML for that single post instead of aborting the entire export with “Jekyll Export failed: Invalid HTML was provided” (#400)

4.0.2

  • Add shutdown handler to surface fatal errors (memory exhaustion, max execution time) during export with actionable error messages instead of a generic WordPress critical error page
  • Add proactive memory_limit pre-flight check (warns when below 64MB) in validate_environment()
  • Display admin error notice on Tools Export when environment validation fails, before the user clicks Export

4.0.1

  • Security: Use cryptographically secure randomness (wp_generate_password) instead of md5(time()) for the export temp directory name to prevent symlink/TOCTOU attacks on shared hosts (CWE-330/377)
  • Security: Reject non-CLI access in deprecated jekyll-export-cli.php before bootstrapping WordPress (CWE-665)
  • Security: Sanitize each path segment of page filenames as defense-in-depth against path traversal (CWE-22)
  • Fix stale $upload_basedir cache in copy_recursive() on multisite by keying it on the current blog ID

4.0.0

  • Breaking: Minimum PHP version bumped from 7.2.5 to 8.2
  • Breaking: Minimum WordPress version bumped from 4.4 to 6.4
  • Updated symfony/yaml from ^5.4 to ^7.0
  • Updated PHPUnit from ~8.0 to ~9.6
  • Removed symfony/polyfill-php80 (no longer needed)
  • Added PHPStan static analysis at level 5
  • Fixed get_posts() to return integer IDs instead of strings
  • Fixed PHPDoc type annotations throughout codebase
  • Deprecated legacy jekyll-export-cli.php in favor of lib/cli.php
  • Improved CI pipeline with PHPStan job and vendor consistency checks

View Past Releases

Back to top