
Eleventy with headless WordPress: WordPress setup
8 March 2024
I’ve been enjoying the ease of Eleventy to generate static sites recently, building a few low maintenance sites for clients and one site that got huge spikes of traffic once a year. When setting up this site, I wanted the lightweightness of Eleventy, but I’m too much of a WordPress dev to imagine writing in markdown, or gods forbid, trying a more jamstacky cms. So headless WordPress.
I had a few requirements from the WordPress side: custom REST endpoints because the native ones don’t always provide the data in a useful way (featured images and taxonomies require additional trips); no frontend; ability to trigger Eleventy build on publishing and deleting; and custom blocks (I ended up disabling almost all the native blocks so I could control the markup).
On the Eleventy side the main requirement was to host it with my standard hosting provider which uses a regular LAMP stack, though it allows Node applications in a kind of containerised way. I also needed some custom fetch functions to get data from WP and some webhooks that could be triggered by WP.
This post will just look at the WordPress setup. First step was to disable the backend. I created a plugin with the following:
add_action('init', 'redirect_to_backend');
function redirect_to_backend() {
if( !current_user_can( 'manage_options' ) &&
!is_admin() &&
!is_wplogin() &&
!is_rest()
) {
wp_redirect(site_url('wp-admin'));
exit();
}
}
if (!function_exists('is_rest')) {
function is_rest() {
$prefix = rest_get_url_prefix( );
if (defined('REST_REQUEST') && REST_REQUEST)
|| isset($_GET['rest_route'])
&& strpos( trim( $_GET['rest_route'], '\\/' ), $prefix , 0 ) === 0)
return true;
$rest_url = wp_parse_url( site_url( $prefix ) );
$current_url = wp_parse_url( add_query_arg( array( ) ) );
return strpos( $current_url['path'], $rest_url['path'], 0 ) === 0;
}
}
function is_wplogin(){
$ABSPATH_MY = str_replace(array('\\','/'), DIRECTORY_SEPARATOR, ABSPATH);
return ((in_array($ABSPATH_MY.'wp-login.php', get_included_files()) || in_array($ABSPATH_MY.'wp-register.php', get_included_files()) ) || (isset($_GLOBALS['pagenow']) && $GLOBALS['pagenow'] === 'wp-login.php') || $_SERVER['PHP_SELF']== '/wp-login.php');
}
I hook into init
and then check if the user is an admin (ie me) (I want to be able to access previews, so skipping the redirect if I’m logged in). Then check if we aren’t accessing the login, admin or REST. The is_rest
function comes from this gist.
Then I added the REST endpoints. I put this into functions.php of the theme, but it could equally live in a plugin:
add_action( 'rest_api_init', function () {
register_rest_route( 'chisel/v1', '/get_items', array(
'methods' => 'GET',
'callback' => 'chisel_get_items',
'permission_callback' => '__return_true',
) );
} );
I hook into the rest_api_init
action, and use the register_rest_route
function. I register a get route, with a custom callback. This is a public endpoint as I need to fetch from it from Eleventy without worrying about authentication, so I add '__return_true'
as the permission callback.
function chisel_get_items($data){
$type = $data->get_param('type')?sanitize_text_field($data->get_param('type')):'post';
$id = $data->get_param('id')?intval($data->get_param('id')):null;
$page = $data->get_param('page')?intval($data->get_param('page')):1;
$args = [
'post_type'=>$type,
'posts_per_page'=>10,
'paged'=>$page,
];
if($id){
$args['post__in']=[$id];
$args['posts_per_page']=-1;
}
$query = new WP_Query($args);
$total_posts = $query->found_posts;
$posts = [];
if($query->have_posts( )){
while($query->have_posts( )){
$query->the_post();
global $post;
$this_post = [
'title'=>get_the_title(),
'slug'=>$post->post_name,
'date'=>get_the_date('Y-m-d H:i:s'),
'image'=>get_the_post_thumbnail_url($post->ID,'full'),
'excerpt'=>get_the_excerpt(),
'content'=>apply_filters('the_content',get_the_content()),
'category'=>array_map(function($n){return isset($n->name)?$n->name:'';},get_the_category())
];
$posts[$post->ID]=$this_post;
}
}
return ['items'=>$posts,'totalItems'=>$total_posts];
}
The endpoint takes three parameters, type, id and page. I use the get_param method to get the parameters and then sanitize them using the WP sanitize_text_field
for the string (which strips tags and %-encoded entities) and intval
for the numbers. Type parameter controls the post type we are fetching, id allows us to fetch a single post, while page controls the pagination. I added the pagination so that I don’t end up pulling in 10000 posts in a single request as this blog grows. I use WP_Query
to get results from the database and process them into a nice array.
The final bit of the WordPress setup is pinging the webhook when posts are updated. I use basic auth to prevent any unwanted triggers. This is stored in a .env file.
function chisel_build($body){
$url = $_ENV['CHISEL_SITE'];
$user = $_ENV['CHISEL_USER'];
$auth = $_ENV['CHISEL_AUTH'];
$post = wp_remote_post( $url.'build.php', [
'headers' =>[
'Authorization' => 'Basic ' . base64_encode( $user . ':' . $auth ),
],
'body'=>$body,
'timeout'=>300,
] );
if ( is_wp_error( $post ) ) {
$error_message = $post->get_error_message();
return "Something went wrong: $error_message";
} else {
$body = json_decode($post['body'], true);
if(is_string($body) || NULL==$body || isset($body['output'])&&$body['output']!=''){
return 'Success! '. json_encode($body);
}
else{
return 'Something went wrong. Check the Eleventy server logs.';
}
}
}
I use wp_remote_post
which abstracts away curl. You’ll also notice the endpoint is a php file. More on that in the Eleventy part.
function chisel_post_transition( $new_status, $old_status, $post ) {
$statuses = ['publish','draft','delete'];
if(!in_array($new_status,$statuses)) return;
$body = [];
if($new_status=='publish'){
$body = [
'type'=>$post->post_type,
'id'=>$post->ID,
];
}
if($new_status=='draft'&&$old_status=='publish'||$new_status=='delete'){
$body = [
'build_all'=>true
];
}
chisel_build($body);
}
add_action( 'transition_post_status', 'chisel_post_transition', 10, 3 );
I hook into the transition_post_status
action, which is triggered when a post changes its status. I’m only interested in publish, draft and delete, so we bounce early if it’s not one of those statuses. If it’s post being published we send the type and id, else we trigger a full rebuild.
Finally, I added in a menu page and a link on the admin toolbar to trigger a full rebuild.
function add_chisel_rebuild_link($admin_bar){
$admin_bar->add_menu([
'id'=>'chisel_rebuild','title'=>'Rebuild Chisel','href'=>admin_url().'admin.php?page=chisel&chisel_rebuild=true'
]);
}
add_action('admin_bar_menu', 'add_chisel_rebuild_link', 100);
function add_chisel_menu(){
add_menu_page( 'Chisel', 'Chisel', 'manage_options', 'chisel', 'chisel_menu_page', 'dashicons-admin-site-alt3', 6 );
}
add_action( 'admin_menu', 'add_chisel_menu' );
function chisel_menu_page(){
if(isset($_GET['chisel_rebuild'])){
$message = chisel_build(['build_all'=>true]);
}
?>
<div class="wrap"><h1>Chisel</h1>
<p>Rebuild remote Eleventy site</p>
<?php if(isset($message)){echo '<p>'.$message.'</p>';} ?>
<form method="get" action="<?php echo admin_url(); ?>admin.php?page=chisel">
<input type="hidden" name="page" value="chisel">
<input type="hidden" name="chisel_rebuild" value="true">
<input type="submit" value="Rebuild">
</form>
</div>
<?php
}