Step-by-Step Guide to building a custom XML sitemap generator block for Gutenberg using custom WebAssembly modules
Leveraging WebAssembly for High-Performance XML Sitemap Generation in Gutenberg
Traditional XML sitemap generation in WordPress often relies on PHP, which can become a bottleneck for large sites. This guide details building a custom Gutenberg block that offloads the computationally intensive task of sitemap generation to a WebAssembly (Wasm) module, offering significant performance gains and a more responsive user experience within the WordPress admin. We’ll cover the Wasm module development, integration with a custom Gutenberg block, and the necessary PHP backend for WordPress.
I. Developing the WebAssembly Sitemap Generator
We’ll use Rust for its strong typing, memory safety, and excellent tooling for Wasm compilation. The core logic will involve iterating through WordPress post types and generating the XML structure.
A. Project Setup and Dependencies
Initialize a new Rust project and add the necessary Wasm-bindgen dependencies.
- Install Rust: If you don’t have Rust installed, follow the instructions at rustup.rs.
- Create a new library project:
cargo new --lib wasm_sitemap_generator cd wasm_sitemap_generator
Edit your Cargo.toml to include wasm-bindgen and serde for serialization/deserialization.
[package]
name = "wasm_sitemap_generator"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
wasm-bindgen = "0.2"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
chrono = { version = "0.4", features = ["serde"] }
B. Defining Data Structures
We need structures to represent the data we’ll receive from WordPress (post types, posts) and the final sitemap XML. We’ll use serde for easy JSON serialization/deserialization.
use wasm_bindgen::prelude::*;
use serde::{Serialize, Deserialize};
use chrono::{DateTime, Utc};
#[derive(Serialize, Deserialize)]
pub struct PostData {
pub id: u64,
pub url: String,
pub modified_gmt: String, // Storing as string for simplicity, parse in Rust if needed
pub post_type: String,
pub priority: Option,
pub changefreq: Option,
}
#[derive(Serialize, Deserialize)]
pub struct SitemapConfig {
pub site_url: String,
pub post_types: Vec,
pub default_priority: f32,
pub default_changefreq: String,
}
#[derive(Serialize)]
pub struct SitemapUrl {
pub loc: String,
pub lastmod: String,
pub priority: f32,
pub changefreq: String,
}
#[derive(Serialize)]
pub struct Sitemap {
pub urls: Vec,
}
C. Implementing the Sitemap Generation Logic
The core function will accept a JSON string representing post data and configuration, and return a JSON string of the sitemap XML structure.
#[wasm_bindgen] pub fn generate_sitemap(config_json: &str, posts_json: &str) -> Result{ let config: SitemapConfig = serde_json::from_str(config_json) .map_err(|e| JsValue::from_str(&format!("Failed to parse config: {}", e)))?; let posts: Vec = serde_json::from_str(posts_json) .map_err(|e| JsValue::from_str(&format!("Failed to parse posts: {}", e)))?; let mut sitemap_urls = Vec::new(); for post in posts { if !config.post_types.contains(&post.post_type) { continue; } // Attempt to parse the modified_gmt string into a DateTime object let lastmod_dt = DateTime::parse_from_rfc3339(&post.modified_gmt) .map_err(|e| JsValue::from_str(&format!("Failed to parse date '{}': {}", post.modified_gmt, e)))? .with_timezone(&Utc); let sitemap_url = SitemapUrl { loc: post.url, lastmod: lastmod_dt.format("%Y-%m-%dT%H:%M:%S%z").to_string(), priority: post.priority.unwrap_or(config.default_priority), changefreq: post.changefreq.unwrap_or(config.default_changefreq.clone()), }; sitemap_urls.push(sitemap_url); } let sitemap = Sitemap { urls: sitemap_urls }; serde_json::to_string(&sitemap) .map_err(|e| JsValue::from_str(&format!("Failed to serialize sitemap: {}", e))) }
D. Compiling to WebAssembly
Use wasm-pack to build the Wasm module. Ensure you have it installed: cargo install wasm-pack.
wasm-pack build --target web --out-dir ../../public/wasm
This command compiles the Rust code into a WebAssembly module and generates JavaScript bindings, placing them in the ../../public/wasm directory (relative to your Rust project root). Adjust the output path as needed for your WordPress plugin structure.
II. Creating the Gutenberg Block
We’ll create a custom Gutenberg block that interacts with the Wasm module. This involves setting up the block’s JavaScript/React components and the PHP registration.
A. Block Registration (PHP)
Register the block using WordPress’s plugin API. This will enqueue the necessary JavaScript and Wasm files.
/**
* Plugin Name: Custom Sitemap Generator Block
* Description: A Gutenberg block for generating XML sitemaps using WebAssembly.
* Version: 1.0.0
* Author: Your Name
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
function custom_sitemap_block_register() {
$asset_file = include( plugin_dir_path( __FILE__ ) . 'build/index.asset.php');
wp_register_script(
'custom-sitemap-block-editor-script',
plugin_dir_url( __FILE__ ) . 'build/index.js',
$asset_file['dependencies'],
$asset_file['version']
);
wp_register_script(
'wasm-sitemap-generator',
plugin_dir_url( __FILE__ ) . 'public/wasm/wasm_sitemap_generator_bg.js', // Path to the JS bindings
array(),
'1.0.0',
true // Load in footer
);
wp_localize_script( 'custom-sitemap-block-editor-script', 'customSitemapBlock', array(
'wasmModuleUrl' => plugin_dir_url( __FILE__ ) . 'public/wasm/wasm_sitemap_generator_bg.wasm', // Path to the .wasm file
'ajax_url' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'custom_sitemap_nonce' ),
));
register_block_type( 'custom-sitemap/generator', array(
'editor_script' => 'custom-sitemap-block-editor-script',
'editor_style' => 'custom-sitemap-block-editor-style', // If you have one
'render_callback' => 'custom_sitemap_block_render_callback',
) );
}
add_action( 'init', 'custom_sitemap_block_register' );
// Optional: Render callback for frontend display if needed
function custom_sitemap_block_render_callback( $attributes ) {
// This block is primarily for the admin interface.
// If you need frontend rendering, implement it here.
return '';
}
// AJAX handler for fetching posts
function custom_sitemap_fetch_posts_handler() {
check_ajax_referer( 'custom_sitemap_nonce', 'nonce' );
$post_types = isset( $_POST['post_types'] ) ? array_map( 'sanitize_text_field', $_POST['post_types'] ) : array();
$posts_per_page = isset( $_POST['posts_per_page'] ) ? intval( $_POST['posts_per_page'] ) : 100;
$paged = isset( $_POST['paged'] ) ? intval( $_POST['paged'] ) : 1;
$args = array(
'post_type' => $post_types,
'posts_per_page' => $posts_per_page,
'paged' => $paged,
'post_status' => 'publish',
'orderby' => 'modified',
'order' => 'DESC',
);
$query = new WP_Query( $args );
$posts_data = array();
if ( $query->have_posts() ) {
while ( $query->have_posts() ) {
$query->the_post();
$posts_data[] = array(
'id' => get_the_ID(),
'url' => get_permalink(),
'modified_gmt' => get_post_modified_time( 'c', true ), // RFC 3339 format
'post_type' => get_post_type(),
'priority' => null, // Placeholder for custom logic
'changefreq' => null, // Placeholder for custom logic
);
}
wp_reset_postdata();
}
wp_send_json_success( array(
'posts' => $posts_data,
'max_pages' => $query->max_num_pages,
) );
}
add_action( 'wp_ajax_custom_sitemap_fetch_posts', 'custom_sitemap_fetch_posts_handler' );
// AJAX handler for generating the sitemap
function custom_sitemap_generate_handler() {
check_ajax_referer( 'custom_sitemap_nonce', 'nonce' );
$config = isset( $_POST['config'] ) ? $_POST['config'] : array();
$posts_data = isset( $_POST['posts'] ) ? $_POST['posts'] : array();
// Sanitize config
$sanitized_config = array(
'site_url' => esc_url_raw( home_url() ),
'post_types' => array_map( 'sanitize_text_field', $config['post_types'] ?? array() ),
'default_priority' => floatval( $config['default_priority'] ?? 0.5 ),
'default_changefreq' => sanitize_text_field( $config['default_changefreq'] ?? 'weekly' ),
);
// Sanitize posts data (basic sanitization, more robust needed for production)
$sanitized_posts_data = array_map( function($post) {
return array(
'id' => intval($post['id']),
'url' => esc_url_raw($post['url']),
'modified_gmt' => sanitize_text_field($post['modified_gmt']),
'post_type' => sanitize_text_field($post['post_type']),
'priority' => isset($post['priority']) ? floatval($post['priority']) : null,
'changefreq' => isset($post['changefreq']) ? sanitize_text_field($post['changefreq']) : null,
);
}, $posts_data);
// Prepare data for Wasm module
$config_json = json_encode( $sanitized_config );
$posts_json = json_encode( $sanitized_posts_data );
// This part requires the Wasm module to be accessible from the server-side
// or a client-side call. For simplicity, we'll assume a client-side call
// or a server-side Wasm runtime (e.g., Wasmtime, Wasmer).
// For a typical WordPress plugin, the Wasm execution happens in the browser.
// The PHP backend's role is to *fetch* the data needed by the Wasm module.
// The actual Wasm execution and sitemap generation will be triggered from JS.
// If you were to run Wasm server-side, you'd use a PHP extension or a separate process.
// For this guide, we'll focus on the client-side execution triggered by the block.
// The response here will be handled by the JS part of the block.
// We'll return success to indicate the data is ready for JS processing.
wp_send_json_success( array(
'message' => 'Data prepared for Wasm generation.',
'config_json' => $config_json,
'posts_json' => $posts_json,
) );
}
add_action( 'wp_ajax_custom_sitemap_generate', 'custom_sitemap_generate_handler' );
// AJAX handler to save the generated sitemap XML
function custom_sitemap_save_sitemap_handler() {
check_ajax_referer( 'custom_sitemap_nonce', 'nonce' );
$sitemap_xml = isset( $_POST['sitemap_xml'] ) ? $_POST['sitemap_xml'] : '';
if ( empty( $sitemap_xml ) ) {
wp_send_json_error( array( 'message' => 'No sitemap XML provided.' ) );
return;
}
// Sanitize XML content - this is crucial and complex.
// For simplicity, we'll assume the Wasm output is trusted.
// In a real-world scenario, you'd validate against an XML schema.
$upload_dir = wp_upload_dir();
$sitemap_dir = $upload_dir['basedir'] . '/custom-sitemaps/';
if ( ! file_exists( $sitemap_dir ) ) {
wp_mkdir_p( $sitemap_dir );
}
$file_path = $sitemap_dir . 'sitemap.xml';
$result = file_put_contents( $file_path, $sitemap_xml );
if ( $result === false ) {
wp_send_json_error( array( 'message' => 'Failed to save sitemap file.' ) );
} else {
// Optionally, update a WordPress option to store the sitemap URL
update_option( 'custom_sitemap_url', $upload_dir['baseurl'] . '/custom-sitemaps/sitemap.xml' );
wp_send_json_success( array( 'message' => 'Sitemap saved successfully.', 'url' => $upload_dir['baseurl'] . '/custom-sitemaps/sitemap.xml' ) );
}
}
add_action( 'wp_ajax_custom_sitemap_save_sitemap', 'custom_sitemap_save_sitemap_handler' );
B. Block Editor JavaScript (React)
This is the core of the Gutenberg block. It will handle user interface elements, fetch post data via AJAX, call the Wasm module, and display the results.
/**
* WordPress dependencies
*/
const { registerBlockType } = wp.blocks;
const { useState, useEffect, useCallback } = wp.element;
const { InspectorControls, PanelColorSettings } = wp.blockEditor;
const { PanelBody, Button, SelectControl, TextControl, Spinner, Placeholder, Notice } = wp.components;
const { __ } = wp.i18n;
/**
* Internal dependencies
*/
import './style.scss'; // For editor styles
import './editor.scss'; // For editor styles
// Assume wasm_sitemap_generator is globally available after loading the JS bindings
// from the PHP registration.
// Example: const { generate_sitemap } = window.wasm_sitemap_generator;
const Edit = ( { attributes, setAttributes } ) => {
const [ isLoading, setIsLoading ] = useState( false );
const [ error, setError ] = useState( null );
const [ sitemapUrl, setSitemapUrl ] = useState( '' );
const [ posts, setPosts ] = useState( [] );
const [ config, setConfig ] = useState( {
post_types: [ 'post', 'page' ],
default_priority: 0.5,
default_changefreq: 'weekly',
} );
const [ availablePostTypes, setAvailablePostTypes ] = useState( [] );
const [ currentPage, setCurrentPage ] = useState( 1 );
const [ maxPages, setMaxPages ] = useState( 1 );
// Fetch available post types on mount
useEffect( () => {
wp.apiFetch( { path: '/wp/v2/types' } ).then( ( types ) => {
const selectableTypes = Object.keys( types )
.filter( type => types[ type ].viewable && types[ type ].public )
.map( type => ( { label: types[ type ].name, value: type } ) );
setAvailablePostTypes( selectableTypes );
} );
}, [] );
// Load Wasm module and initialize
useEffect( () => {
const loadWasmModule = async () => {
if ( typeof window.wasm_sitemap_generator === 'undefined' ) {
setError( __( 'WebAssembly module not loaded. Please check plugin setup.', 'custom-sitemap' ) );
return;
}
// The wasm_sitemap_generator_bg.js file should have exposed the generate_sitemap function.
// We can test if it's available.
if ( typeof window.wasm_sitemap_generator.generate_sitemap === 'undefined' ) {
setError( __( 'WebAssembly sitemap function not found.', 'custom-sitemap' ) );
}
};
loadWasmModule();
}, [] );
const fetchPosts = useCallback( async ( page = 1 ) => {
setIsLoading( true );
setError( null );
try {
const response = await wp.apiFetch( {
path: customSitemapBlock.ajax_url,
method: 'POST',
data: {
action: 'custom_sitemap_fetch_posts',
nonce: customSitemapBlock.nonce,
post_types: config.post_types,
paged: page,
posts_per_page: 100, // Fetch in batches
},
} );
if ( response.success ) {
setPosts( response.data.posts );
setMaxPages( response.data.max_pages );
setCurrentPage( page );
} else {
setError( response.data.message || __( 'Failed to fetch posts.', 'custom-sitemap' ) );
}
} catch ( e ) {
setError( e.message || __( 'An error occurred while fetching posts.', 'custom-sitemap' ) );
} finally {
setIsLoading( false );
}
}, [ config.post_types ] );
const handleGenerateSitemap = async () => {
if ( typeof window.wasm_sitemap_generator.generate_sitemap === 'undefined' ) {
setError( __( 'WebAssembly module is not ready.', 'custom-sitemap' ) );
return;
}
setIsLoading( true );
setError( null );
// First, ensure we have the latest posts data
await fetchPosts(1); // Fetch first page to ensure data is fresh
if ( posts.length === 0 ) {
setError( __( 'No posts found for the selected post types.', 'custom-sitemap' ) );
setIsLoading( false );
return;
}
try {
const configJson = JSON.stringify( config );
const postsJson = JSON.stringify( posts );
// Call the Wasm function
const resultJson = window.wasm_sitemap_generator.generate_sitemap( configJson, postsJson );
if ( resultJson instanceof Error ) {
throw resultJson;
}
const sitemapData = JSON.parse( resultJson );
// Format the sitemap XML from the Wasm output
let sitemapXml = '<?xml version="1.0" encoding="UTF-8"?>\n';
sitemapXml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"\n';
sitemapXml += ' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"\n';
sitemapXml += ' xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9\n';
sitemapXml += ' http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">\n';
sitemapData.urls.forEach( urlEntry => {
sitemapXml += ` <url>\n`;
sitemapXml += ` <loc>${urlEntry.loc}</loc>\n`;
sitemapXml += ` <lastmod>${urlEntry.lastmod}</lastmod>\n`;
sitemapXml += ` <changefreq>${urlEntry.changefreq}</changefreq>\n`;
sitemapXml += ` <priority>${urlEntry.priority.toFixed(1)}</priority>\n`;
sitemapXml += ` </url>\n`;
} );
sitemapXml += '</urlset>';
// Save the sitemap XML via AJAX
const saveResponse = await wp.apiFetch( {
path: customSitemapBlock.ajax_url,
method: 'POST',
data: {
action: 'custom_sitemap_save_sitemap',
nonce: customSitemapBlock.nonce,
sitemap_xml: sitemapXml,
},
} );
if ( saveResponse.success ) {
setSitemapUrl( saveResponse.data.url );
setError( null ); // Clear previous errors
} else {
setError( saveResponse.data.message || __( 'Failed to save sitemap.', 'custom-sitemap' ) );
}
} catch ( e ) {
console.error( "Wasm generation error:", e );
setError( e.message || __( 'An error occurred during sitemap generation.', 'custom-sitemap' ) );
} finally {
setIsLoading( false );
}
};
const handlePostTypeChange = ( value ) => {
setConfig( { ...config, post_types: value } );
setPosts([]); // Clear posts when post types change
setSitemapUrl(''); // Clear saved URL
};
const handlePriorityChange = ( value ) => {
setConfig( { ...config, default_priority: parseFloat( value ) || 0.5 } );
};
const handleChangefreqChange = ( value ) => {
setConfig( { ...config, default_changefreq: value } );
};
const handleFetchClick = () => {
fetchPosts(1);
};
const handleNextPage = () => {
if ( currentPage < maxPages ) {
fetchPosts( currentPage + 1 );
}
};
const handlePrevPage = () => {
if ( currentPage > 1 ) {
fetchPosts( currentPage - 1 );
}
};
if ( error ) {
return (
<Placeholder label={ __( 'Sitemap Generator', 'custom-sitemap' ) }>
<Notice status="error" isDismissible={false}>{error}</Notice>
<Button isPrimary onClick={handleFetchClick} disabled={isLoading}>{ __( 'Retry Fetch Posts', 'custom-sitemap' ) }</Button>
</Placeholder>
);
}
if ( isLoading ) {
return (
<Placeholder label={ __( 'Sitemap Generator', 'custom-sitemap' ) }>
<Spinner />
<p>{ __( 'Processing...', 'custom-sitemap' ) }</p>
</Placeholder>
);
}
return (
<div className="custom-sitemap-block">
<InspectorControls>
<PanelBody title={ __( 'Sitemap Settings', 'custom-sitemap' ) } initialOpen={ true }>
<SelectControl
label={ __( 'Post Types', 'custom-sitemap' ) }
multiple
help={ __( 'Select which post types to include.', 'custom-sitemap' ) }
options={ availablePostTypes }
value={ config.post_types }
onChange={ handlePostTypeChange }
/>
<TextControl
label={ __( 'Default Priority', 'custom-sitemap' ) }
type="number"
step="0.1"
min="0.0"
max="1.0"
value={ config.default_priority }
onChange={ handlePriorityChange }
help={ __( 'Default priority for URLs (0.0 to 1.0).', 'custom-sitemap' ) }
/>
<SelectControl
label={ __( 'Default Change Frequency', 'custom-sitemap' ) }
value={ config.default_changefreq }
options={ [
{ label: __( 'Always', 'custom-sitemap' ), value: 'always' },
{ label: __( 'Hourly', 'custom-sitemap' ), value: 'hourly' },
{ label: __( 'Daily', 'custom-sitemap' ), value: 'daily' },
{ label: __( 'Weekly', 'custom-sitemap' ), value: 'weekly' },
{ label: __( 'Monthly', 'custom-sitemap' ), value: 'monthly' },
{ label: __( 'Yearly', 'custom-sitemap' ), value: 'yearly' },
{ label: __( 'Never', 'custom-sitemap' ), value: 'never' },
] }
onChange={ handleChangefreqChange }
help={ __( 'Default change frequency for URLs.', 'custom-sitemap' ) }
/>
<Button isPrimary onClick={ handleFetchClick } disabled={ isLoading || availablePostTypes.length === 0 }>
{ __( 'Fetch Posts', 'custom-sitemap' ) }
</Button>
</PanelBody>
</InspectorControls>
<Placeholder label={ __( 'XML Sitemap Generator', 'custom-sitemap' ) }>
{ availablePostTypes.length === 0 && !error && <Spinner /> }
{ availablePostTypes.length > 0 && (
<>
<p>{ __( 'Configure your sitemap settings in the sidebar.', 'custom-sitemap' ) }</p>
{ posts.length > 0 && (
<div>
<p>{ __( 'Found', 'custom-sitemap' ) } { posts.length } { __( 'posts for the selected post types.', 'custom-sitemap' ) }</p>
<div className="pagination">
<Button onClick={ handlePrevPage } disabled={ currentPage === 1 }>{ __( 'Previous', 'custom-sitemap' ) }</Button>
<span>{ currentPage } / { maxPages }</span>
<Button onClick={ handleNextPage } disabled={ currentPage === maxPages }>{ __( 'Next', 'custom-sitemap' ) }</Button>
</div>
</div>
) }
<Button isPrimary onClick={ handleGenerateSitemap } disabled={ isLoading || posts.length === 0 }>
{ __( 'Generate & Save Sitemap', 'custom-sitemap' ) }
</Button>
{ sitemapUrl && (
<p>
{ __( 'Sitemap saved at:', 'custom-sitemap' ) }
<a href={ sitemapUrl } target="_blank" rel="noopener noreferrer">{ sitemapUrl }</a>
</p>
) }
</>
) }
</Placeholder>
</div>
);
};
registerBlockType( 'custom-sitemap/generator', {
title: __( 'XML Sitemap Generator', 'custom-sitemap' ),
icon: 'admin-site-alt3', // Choose an appropriate icon
category: 'widgets', // Or 'design', 'plugins', etc.
edit: Edit,
save: () => null, // This block is purely for the editor/admin interface
} );
C. Build Process
You’ll need Node.js and npm/yarn. Use `@wordpress/scripts` to compile your JavaScript and SCSS.
# Navigate to your plugin directory cd /path/to/your/wordpress/wp-content/plugins/custom-sitemap-block/ # Install dependencies npm install # Build the block assets npm run build
This will create the build/index.js and build/index.asset.php files, which are referenced in the PHP registration. Ensure your Wasm output directory (e.g., public/wasm) is correctly placed relative to your main plugin file.
III. Deployment and Usage
A. Plugin Structure
Your plugin directory should look something like this:
custom-sitemap-block/ ├── build/ │ ├── index.asset.php │ └── index.js ├── public/ │ └── wasm/ │ ├── wasm_sitemap_generator_bg.js │ └── wasm_sitemap_generator_bg.wasm ├── src/ │ ├── editor.scss │ ├── index.js │ └── style.scss ├── custom-sitemap-block.php └── package.json └── Cargo.toml (if you keep Rust source within plugin) └── ... (other Rust source files if kept within plugin)
Note: It’s often better practice to build the Wasm module separately and then copy the generated .wasm and _bg.js files into your plugin’s public/wasm/ directory. If you keep the Rust source within the plugin, ensure your wasm-pack build command targets the correct output path.
B. Activating and Using the Block
1. Upload the plugin folder to your WordPress wp-content/plugins/ directory.
2. Activate the “Custom Sitemap Generator Block” plugin from the WordPress admin.
3. Edit any page or post, or create a new one.
4. Click the ‘+’ icon to add a new block and search for “XML Sitemap Generator”.
5. Select the block to add it to the editor.
6. Use the block’s sidebar (Inspector Controls) to configure post types, default priority, and change frequency.
7. Click “Fetch Posts” to retrieve the latest posts based on your configuration.
8. Click “Generate & Save Sitemap” to trigger the Wasm generation and save the resulting sitemap.xml file to your uploads directory (wp-content/uploads/custom-