Step-by-Step Guide to building a custom broken link checker block for Gutenberg using Alpine.js lightweight states
Leveraging Alpine.js for a Custom Gutenberg Broken Link Checker
For e-commerce platforms built on WordPress, maintaining link integrity is paramount. Broken links not only degrade user experience but also negatively impact SEO. While numerous plugins exist, building a custom Gutenberg block offers granular control and a streamlined workflow. This guide details the construction of a lightweight, client-side broken link checker block using Alpine.js for state management, directly within the WordPress editor.
Prerequisites and Project Setup
Before we begin, ensure you have a local WordPress development environment set up. We’ll be creating a custom plugin to house our Gutenberg block. This approach keeps the functionality separate from your theme, making it portable and maintainable.
Create a new directory in your WordPress installation’s wp-content/plugins/ folder, for example, custom-link-checker. Inside this directory, create a main plugin file, custom-link-checker.php.
Plugin Header
Add the standard WordPress plugin header to custom-link-checker.php:
<?php
/**
* Plugin Name: Custom Broken Link Checker
* Description: A custom Gutenberg block to check for broken links within a post or page.
* Version: 1.0.0
* Author: Your Name
* Author URI: https://yourwebsite.com
* License: GPL-2.0+
* License URI: http://www.gnu.org/licenses/gpl-2.0.txt
* Text Domain: custom-link-checker
*/
// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
?>
Gutenberg Block Registration
We need to register our Gutenberg block. This involves enqueueing JavaScript and CSS files that define the block’s behavior and appearance. We’ll use the WordPress Script and Style Enqueuing API.
Enqueueing Block Assets
In custom-link-checker.php, add the following code to register and enqueue the block’s assets:
/**
* Register and enqueue block assets.
*/
function clc_register_block() {
// Register block script.
wp_register_script(
'clc-block-editor-script',
plugins_url( 'build/index.js', __FILE__ ),
array( 'wp-blocks', 'wp-element', 'wp-editor', 'wp-components', 'wp-i18n' ),
filemtime( plugin_dir_path( __FILE__ ) . 'build/index.js' )
);
// Register block style.
wp_register_style(
'clc-block-style',
plugins_url( 'build/style-index.css', __FILE__ ),
array( 'wp-edit-blocks' ),
filemtime( plugin_dir_path( __FILE__ ) . 'build/style-index.css' )
);
// Register block editor style.
wp_register_style(
'clc-block-editor-style',
plugins_url( 'build/index.css', __FILE__ ),
array( 'clc-block-style' ),
filemtime( plugin_dir_path( __FILE__ ) . 'build/index.css' )
);
// Register the block.
register_block_type( 'custom-link-checker/broken-link-checker', array(
'editor_script' => 'clc-block-editor-script',
'editor_style' => 'clc-block-editor-style',
'style' => 'clc-block-style',
) );
}
add_action( 'init', 'clc_register_block' );
Frontend and Editor JavaScript
The core of our block will be in JavaScript. We’ll use the modern `@wordpress/scripts` package to compile our JavaScript and CSS. First, set up your project for compilation. In the root of your plugin directory (custom-link-checker/), create a package.json file:
{
"name": "custom-link-checker",
"version": "1.0.0",
"description": "Custom Gutenberg block for broken link checking.",
"main": "build/index.js",
"scripts": {
"build": "wp-scripts build",
"start": "wp-scripts start"
},
"keywords": [
"wordpress",
"gutenberg",
"block"
],
"author": "Your Name",
"license": "GPL-2.0-or-later",
"devDependencies": {
"@wordpress/scripts": "^26.0.0"
}
}
After creating package.json, run npm install in your terminal within the plugin directory to install the necessary development dependencies.
Block Definition (src/index.js)
Create a src directory in your plugin’s root. Inside src, create index.js. This file will define the block’s attributes, edit function, and save function.
/**
* WordPress dependencies
*/
import { registerBlockType } from '@wordpress/blocks';
import { __ } from '@wordpress/i18n';
import { useBlockProps } from '@wordpress/block-editor';
import { useState, useEffect } from '@wordpress/element';
import { Button, Spinner, TextControl, PanelBody, ToggleControl } from '@wordpress/components';
/**
* Internal dependencies
*/
import './style.scss'; // For frontend styles
import './editor.scss'; // For editor styles
const blockName = 'custom-link-checker/broken-link-checker';
registerBlockType( blockName, {
title: __( 'Broken Link Checker', 'custom-link-checker' ),
icon: 'admin-links',
category: 'widgets',
attributes: {
scanOnLoad: {
type: 'boolean',
default: true,
},
scanInterval: {
type: 'number',
default: 30000, // 30 seconds
},
},
edit: ( { attributes, setAttributes } ) => {
const blockProps = useBlockProps();
const { scanOnLoad, scanInterval } = attributes;
// Alpine.js integration for state management
const [ links, setLinks ] = useState( [] );
const [ checking, setChecking ] = useState( false );
const [ error, setError ] = useState( null );
const [ progress, setProgress ] = useState( 0 );
const [ totalLinks, setTotalLinks ] = useState( 0 );
const handleScan = async () => {
setChecking( true );
setError( null );
setLinks( [] );
setProgress( 0 );
setTotalLinks( 0 );
try {
// In a real-world scenario, this would involve a server-side AJAX request
// to fetch all links from the post content and then perform checks.
// For this example, we'll simulate fetching and checking.
// Simulate fetching links from the editor content.
// This is a simplified representation. A real implementation would parse
// the DOM to extract 'a' tags and their 'href' attributes.
const editorContent = document.querySelector( '.block-editor-block-list__block[data-block="' + blockProps['data-block'] + '"]' );
const anchorTags = editorContent ? editorContent.querySelectorAll( 'a[href]' ) : [];
const extractedLinks = Array.from( anchorTags ).map( a => a.href );
setTotalLinks( extractedLinks.length );
if ( extractedLinks.length === 0 ) {
setChecking( false );
return;
}
const results = [];
for ( const url of extractedLinks ) {
// Simulate checking each link. In a production environment,
// this would be a HEAD request to the URL.
// Due to browser CORS policies, direct AJAX requests to external URLs
// from the browser are restricted. A server-side endpoint is required.
// For demonstration, we'll simulate success/failure.
const isBroken = Math.random() > 0.8; // 20% chance of being broken
results.push( { url, status: isBroken ? 'broken' : 'ok', statusCode: isBroken ? 404 : 200 } );
setProgress( ( prevProgress ) => prevProgress + 1 );
await new Promise( resolve => setTimeout( resolve, scanInterval / extractedLinks.length ) ); // Simulate network latency
}
setLinks( results );
} catch ( e ) {
setError( e.message || __( 'An unknown error occurred.', 'custom-link-checker' ) );
} finally {
setChecking( false );
}
};
// Effect to trigger scan on load if enabled
useEffect( () => {
if ( scanOnLoad ) {
handleScan();
}
}, [ scanOnLoad ] ); // Re-run if scanOnLoad changes
return (
<div { ...blockProps }>
<div x-data="{ open: false }">
<div className="clc-controls">
<h3>{ __( 'Broken Link Checker', 'custom-link-checker' ) }</h3>
<ToggleControl
label={ __( 'Scan on Load', 'custom-link-checker' ) }
checked={ scanOnLoad }
onChange={ ( value ) => setAttributes( { scanOnLoad: value } ) }
/>
<TextControl
label={ __( 'Scan Interval (ms)', 'custom-link-checker' ) }
type="number"
value={ scanInterval }
onChange={ ( value ) => setAttributes( { scanInterval: parseInt( value, 10 ) || 30000 } ) }
help={ __( 'Time between link checks in milliseconds.', 'custom-link-checker' ) }
/>
<Button
variant="primary"
onClick={ handleScan }
disabled={ checking }
>
{ checking ? <Spinner /> : __( 'Scan Links', 'custom-link-checker' ) }
</Button>
</div>
{ checking && (
<div className="clc-progress">
<p>{ __( 'Scanning:', 'custom-link-checker' ) } { progress } / { totalLinks }</p>
<progress value={ progress } max={ totalLinks }></progress>
</div>
) }
{ error && (
<div className="clc-error">
<p>{ __( 'Error:', 'custom-link-checker' ) } { error }</p>
</div>
) }
{ ! checking && links.length > 0 && (
<div className="clc-results">
<h4>{ __( 'Scan Results', 'custom-link-checker' ) }</h4>
<button className="clc-toggle-results" onClick={ () => Alpine.store('clc_results_visible', !Alpine.store('clc_results_visible')) }>
{ __( 'Show/Hide Results', 'custom-link-checker' ) }
</button>
<div x-show="$store.clc_results_visible">
<ul>
{ links.map( ( link, index ) => (
<li key={ index } className={ link.status === 'broken' ? 'clc-broken' : 'clc-ok' }>
<a href={ link.url } target="_blank" rel="noopener noreferrer">{ link.url }</a>
<span>({ link.status === 'broken' ? __( 'Broken', 'custom-link-checker' ) : __( 'OK', 'custom-link-checker' ) } - { link.statusCode })</span>
</li>
) ) }
</ul>
</div>
</div>
) }
</div>
</div>
);
},
save: ( { attributes } ) => {
const blockProps = useBlockProps.save();
// The save function should return null or a static representation
// as the link checking logic is client-side and dynamic.
// We can optionally render a placeholder or instructions.
return (
<div { ...blockProps }>
<p>{ __( 'Broken Link Checker block. Results will appear dynamically in the editor.', 'custom-link-checker' ) }</p>
</div>
);
},
} );
Alpine.js Integration and State Management
While the above uses React hooks for state management within the Gutenberg editor (as is standard), the Alpine.js directives are embedded directly into the JSX for demonstration purposes. In a production scenario, you would typically load Alpine.js separately and initialize it on a specific DOM element. For this block, we’ll simulate Alpine.js behavior using React state and conditionally render elements based on those states. The x-data and x-show attributes are illustrative of how Alpine.js would manage UI state.
A more robust implementation would involve a server-side endpoint to perform the actual link checks, preventing CORS issues and improving performance. The JavaScript would then make an AJAX request to this endpoint.
Styling (src/style.scss and src/editor.scss)
Create src/style.scss for frontend styles and src/editor.scss for editor-specific styles. These will be compiled into build/style-index.css and build/index.css respectively.
/* src/style.scss */
.wp-block-custom-link-checker-broken-link-checker {
border: 1px solid #ccc;
padding: 15px;
margin-bottom: 15px;
background-color: #f9f9f9;
.clc-results ul {
list-style: none;
padding: 0;
margin-top: 10px;
}
.clc-results li {
margin-bottom: 5px;
padding: 5px;
border-radius: 3px;
}
.clc-ok {
background-color: #e6ffed;
color: #28a745;
}
.clc-broken {
background-color: #ffebee;
color: #dc3545;
}
.clc-results span {
font-size: 0.9em;
margin-left: 10px;
}
}
/* src/editor.scss */
.wp-block-custom-link-checker-broken-link-checker {
.clc-controls {
margin-bottom: 15px;
padding-bottom: 15px;
border-bottom: 1px solid #eee;
}
.clc-controls h3 {
margin-top: 0;
}
.clc-progress {
margin-top: 15px;
text-align: center;
}
.clc-error {
margin-top: 15px;
color: #dc3545;
font-weight: bold;
}
.clc-toggle-results {
background: none;
border: none;
color: #0073aa;
cursor: pointer;
padding: 0;
font-size: 1em;
margin-bottom: 10px;
text-decoration: underline;
}
}
Building the Block Assets
With the JavaScript and SCSS files in place, navigate to your plugin’s root directory in the terminal and run the build command:
npm run build
This command will compile your SCSS files and transpile your JavaScript, creating the necessary files in the build/ directory. If you want to watch for changes during development, use:
npm run start
Server-Side Link Checking (Recommended)
The client-side JavaScript approach for link checking has limitations, primarily due to browser security policies (CORS) that prevent direct AJAX requests to arbitrary external URLs. For a production-ready solution, you must implement a server-side endpoint to perform the HTTP requests.
Creating a REST API Endpoint
Add the following to your custom-link-checker.php file to create a REST API endpoint:
/**
* Register REST API endpoint for link checking.
*/
function clc_register_rest_endpoint() {
register_rest_route( 'custom-link-checker/v1', '/check-links', array(
'methods' => 'POST',
'callback' => 'clc_check_links_callback',
'permission_callback' => function () {
// Ensure only logged-in users with sufficient permissions can access.
return current_user_can( 'edit_posts' );
},
'args' => array(
'content' => array(
'required' => true,
'type' => 'string',
'sanitize_callback' => 'wp_kses_post',
),
),
) );
}
add_action( 'rest_api_init', 'clc_register_rest_endpoint' );
/**
* Callback function for the link checking REST API endpoint.
*
* @param WP_REST_Request $request Full data about the request.
* @return WP_REST_Response Response object.
*/
function clc_check_links_callback( WP_REST_Request $request ) {
$content = $request->get_param( 'content' );
if ( empty( $content ) ) {
return new WP_Error( 'invalid_content', __( 'Content is required.', 'custom-link-checker' ), array( 'status' => 400 ) );
}
// Use DOMDocument to parse HTML and extract links.
$dom = new DOMDocument();
// Suppress warnings for malformed HTML.
@$dom->loadHTML( mb_convert_encoding( $content, 'HTML-ENTITIES', 'UTF-8' ) );
$links = array();
$anchor_tags = $dom->getElementsByTagName( 'a' );
foreach ( $anchor_tags as $tag ) {
$href = $tag->getAttribute( 'href' );
if ( ! empty( $href ) && ( strpos( $href, 'http://' ) === 0 || strpos( $href, 'https://' ) === 0 ) ) {
$links[] = esc_url_raw( $href ); // Sanitize URL
}
}
// Remove duplicates
$links = array_unique( $links );
$results = array();
$timeout = 5; // seconds
foreach ( $links as $url ) {
$response_code = null;
$error_message = null;
$args = array(
'timeout' => $timeout,
'sslverify' => false, // Consider security implications
'user-agent' => 'WordPress Broken Link Checker Bot',
);
// Use wp_remote_head for efficiency, fall back to wp_remote_get if needed.
$response = wp_remote_head( $url, $args );
if ( is_wp_error( $response ) ) {
$error_message = $response->get_error_message();
$response_code = 'error';
} else {
$response_code = wp_remote_retrieve_response_code( $response );
}
$results[] = array(
'url' => $url,
'status' => ( $response_code >= 400 || $response_code === 'error' ) ? 'broken' : 'ok',
'statusCode' => $response_code,
'errorMessage' => $error_message,
);
}
return new WP_REST_Response( $results, 200 );
}
Updating the Editor JavaScript
Modify the handleScan function in src/index.js to use the new REST API endpoint:
// ... inside the edit function ...
const handleScan = async () => {
setChecking( true );
setError( null );
setLinks( [] );
setProgress( 0 );
setTotalLinks( 0 );
try {
// Get current post content (this is a simplified approach)
// In a real scenario, you'd likely need to get the *saved* post content
// or use a more robust method to capture all block content.
// For this example, we'll assume we can get the content.
// A more accurate way might involve fetching the post content via AJAX
// or using the Block Editor's internal state if available.
// For demonstration, let's simulate getting content.
// In a real plugin, you'd likely pass the post ID and fetch content server-side,
// or use a method to serialize the current editor state.
// Here, we'll just use a placeholder and assume the server endpoint handles it.
// A better approach for the editor would be to serialize the blocks and send.
// For simplicity, we'll just send a dummy content string and rely on the server
// to parse the *actual* post content if it were a real AJAX call from the frontend.
// Or, better yet, pass the current editor content to the endpoint.
// Let's try to get the current editor content. This is tricky as it's dynamic.
// A more reliable method is to fetch the post content via AJAX using the post ID.
// For this example, we'll simulate sending the content.
// A robust solution would involve `wp.data.select('core/editor').getEditedPostContent()`
// but this requires careful handling of serialization and AJAX.
// Let's simulate fetching the content for the AJAX call.
// In a real scenario, you'd get the current post's content.
// For this example, we'll use a placeholder and assume the server endpoint
// would receive the actual content to parse.
// A more practical approach for the editor:
const currentPostContent = wp.data.select('core/editor').getEditedPostContent();
const response = await fetch( '/wp-json/custom-link-checker/v1/check-links', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': wpApiSettings.nonce, // WordPress REST API nonce
},
body: JSON.stringify( { content: currentPostContent } ),
} );
if ( ! response.ok ) {
const errorData = await response.json();
throw new Error( errorData.message || __( 'Failed to check links.', 'custom-link-checker' ) );
}
const data = await response.json();
setLinks( data );
setTotalLinks( data.length ); // Assuming the response contains all links checked
setProgress( data.length ); // Assuming all links are processed by the time response is received
} catch ( e ) {
setError( e.message || __( 'An unknown error occurred.', 'custom-link-checker' ) );
} finally {
setChecking( false );
}
};
// ... rest of the edit function ...
Note the use of wpApiSettings.nonce for authentication with the WordPress REST API. Ensure this is correctly enqueued in your PHP file if not already available.
Conclusion and Further Enhancements
This guide provides a foundational custom Gutenberg block for broken link checking, integrating client-side state management concepts with a robust server-side API for actual checks. The use of Alpine.js principles (even if simulated with React state in the editor) allows for a dynamic and interactive user experience.
Further Enhancements:
- Background Scanning: Implement a WordPress cron job or WP-CLI command for periodic background scans of published posts.
- Link Management Interface: Create a dedicated admin page to list all broken links across the site, with options to edit or ignore them.
- External Link Checking: Integrate with third-party APIs for more comprehensive link validation.
- Performance Optimization: For very large posts, consider debouncing the scan input and optimizing the server-side parsing and request handling.
- User Feedback: Provide more detailed feedback during the scanning process, including specific error messages from the server.