
The other day I had a little bit of time off and decided it was about time to redesign one of my sites.
I asked Claude to recreate the homepage, and honestly, it did such a good job that I was genuinely impressed. The design was built with Tailwind CSS.
Up until then, Iβd always asked Claude to recreate designs using vanilla CSS. It could do it, but the results never felt quite as polished as the Tailwind version.
So I decided to stick with Tailwind. Since then, Iβve already used it on three different sites and even in a custom PHP app.
Tailwind on Production
When you create a design using Tailwind CSS, you are gonna find this script on your source code:
<script src="https://cdn.tailwindcss.com"></script>
That code includes a lot of extra stuffβthings youβll probably never even use.
that script is also pretty heavy for most sites, and any WordPress speed expert would probably lose their mind if you just left it like that.
My Tailwind Plugin
I know there are probably plugins out there that can scan your themes and plugins for the Tailwind classes youβre actually using and then only enqueue the corresponding CSS.
However but I have eliminated my dependency on plugins.
When I need one, I rather create it using Artificial Intelligence.
This is the structure of the plugin that Claude provided for me.
wp-content/
βββ mu-plugins/
βββ tailwind-handler/
βββ tailwind.php # Main plugin file
βββ package.json # npm dependencies
βββ tailwind.config.js # Tailwind configuration
βββ postcss.config.js # PostCSS configuration
βββ src/
β βββ input.css # Source Tailwind file
βββ dist/
β βββ tailwind.min.css # Compiled output (gitignored)
βββ node_modules/ # gitignored
package.json
{
"name": "tailwind-handler",
"version": "1.0.0",
"description": "Tailwind CSS build system for WordPress",
"scripts": {
"build": "tailwindcss -i ./src/input.css -o ./dist/tailwind.min.css --minify",
"watch": "tailwindcss -i ./src/input.css -o ./dist/tailwind.min.css --watch"
},
"devDependencies": {
"tailwindcss": "^3.4.0"
}
}
These are npm commands you can run from the terminal:
- npm run build is a one-time command that generates a fully minified CSS file, which is ideal for production or deployment when youβre finished making changes.
- npm run watch, on the other hand, keeps running in the background while you work, automatically rebuilding the CSS whenever you change Tailwind classes in your templates, so you donβt have to manually recompile during development.
You can change the location of where the file will be outputted, that ‘s really helpful for those of us who want to lock down wp-content and use a public folder instead.
tailwind.config.js
This file tells Tailwind where to look for class names and how to build your CSS.
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'../../themes/**/*.php',
'../../themes/**/*.html',
'../../themes/**/*.js',
],
theme: {
extend: {
// Add your custom theme extensions here
},
},
plugins: [],
}
If a file is missing from content, its classes will not appear in your final CSS.
postcss.config.js
This tells PostCSS which plugins to run when processing your CSS.
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
src/input.css
Tailwind covers most layout and styling needs, but there are still cases where custom CSS (the part ) is useful or necessary.
You can add all of that under custom CSS and that will be included on Tailwind file generated by the plugin.
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Add any custom CSS here */
NPM Install
Go to the folder where your Tailwind mu-plugin is
Run this command
npm install
You will get this message on your terminal
up to date, audited 74 packages in 705ms
18 packages are looking for funding
run `npm fund` for details
found 0 vulnerabilities
Result
Now, all I had to do was to go to the folder via the terminal and run this command:
npm run build
then a CSS file would be generated when the process was over and all I had to do was to link to that file from the header
The problem was that the file is created within the mu-plugin and that’s not a folder that I intend for the web to access
so I had to move it to the assets’ folder manually
<link rel="stylesheet" href="<?php echo site_url('/assets/files/tailwind.min.css'); ?>" type="text/css" media="all" />
Not much work but I don’t want to do that manually every time I changed something on the theme.
My Buddy Claude
My Buddy Claude told me that the plugin could handle the process but I needed to install Node Package Manager.
I am working locally using Podman so I have to do this for the container where that site was built on.
Step #1 – Access the Container
podman exec -it tlb_wordpress_site bas
Step #2 – Check the OS
cat /etc/os-release
Step #3 – Install NodeJS and NPM
apt-get update
apt-get install -y nodejs npm
Step #4 – Verify installation
which npm
npm --version
Step #5 – Exit Container
exit
Main
This is the main file that ensure I can rebuild the file from my WordPress dashboard so I don’t have to type a line of code.
It works
Maybe there is something on it which it shouldn’t be there.
<?php
/**
* Plugin Name: Tailwind CSS Handler for WordPress (Local + Podman)
* Description: Compiles and manages Tailwind CSS for WordPress Themes
* Version: 2.0.0
*/
if (!defined('ABSPATH')) {
exit; // Exit if accessed directly
}
class TailwindHandler {
private $plugin_dir;
private $plugin_url;
private $dist_file;
private $copy_destination_dir;
private $copy_destination_file;
private $copy_destination_url;
private $npm_path_override = null; // Set in constructor if needed
public function __construct() {
// Initialize paths
$this->plugin_dir = dirname(__FILE__);
$this->plugin_url = plugins_url('', __FILE__);
$this->dist_file = $this->plugin_dir . '/dist/tailwind.min.css';
// MANUAL NPM PATH OVERRIDE - For Podman/Docker containers
// Set to null to use auto-detection, or specify the path
$this->npm_path_override = '/usr/bin/npm'; // Standard path in Debian-based containers
// Define the target copy path as absolute path in WordPress root
$this->copy_destination_dir = ABSPATH . 'assets/files';
$this->copy_destination_file = $this->copy_destination_dir . '/tailwind.min.css';
$this->copy_destination_url = home_url('/assets/files/tailwind.min.css');
// Hook into WordPress
add_action('wp_enqueue_scripts', [$this, 'enqueue_tailwind']);
add_action('admin_notices', [$this, 'check_compiled_css']);
add_action('admin_post_rebuild_tailwind', [$this, 'rebuild_tailwind']);
add_action('admin_menu', [$this, 'add_admin_page']);
// WP-CLI command
if (defined('WP_CLI') && WP_CLI) {
WP_CLI::add_command('tailwind rebuild', [$this, 'cli_rebuild']);
}
}
/**
* Enqueue the compiled Tailwind CSS from the copied location
*/
public function enqueue_tailwind() {
if (file_exists($this->copy_destination_file)) {
wp_enqueue_style(
'tailwind-css',
$this->copy_destination_url,
[],
filemtime($this->copy_destination_file)
);
}
}
/**
* Check if compiled CSS exists and show notice if missing
*/
public function check_compiled_css() {
if (!file_exists($this->copy_destination_file) && current_user_can('manage_options')) {
$rebuild_url = wp_nonce_url(
admin_url('admin-post.php?action=rebuild_tailwind'),
'rebuild_tailwind_nonce'
);
?>
<div class="notice notice-warning is-dismissible">
<p>
<strong>Tailwind CSS:</strong> Compiled CSS file not found.
<a href="<?php echo esc_url($rebuild_url); ?>" class="button button-primary">
Build Tailwind CSS now
</a>
</p>
</div>
<?php
}
}
/**
* Add admin page under Tools menu
*/
public function add_admin_page() {
add_management_page(
'Tailwind CSS Handler',
'Tailwind CSS',
'manage_options',
'tailwind',
[$this, 'admin_page']
);
}
/**
* Render admin page
*/
public function admin_page() {
if (!current_user_can('manage_options')) {
wp_die(__('You do not have sufficient permissions to access this page.'));
}
// Check various statuses
$compiled_exists = file_exists($this->dist_file);
$compiled_time = $compiled_exists ? date('Y-m-d H:i:s', filemtime($this->dist_file)) : 'Never';
$node_modules_exists = is_dir($this->plugin_dir . '/node_modules');
$copied_exists = file_exists($this->copy_destination_file);
$copied_time = $copied_exists ? date('Y-m-d H:i:s', filemtime($this->copy_destination_file)) : 'Never';
// Check directory permissions
$dest_dir_exists = is_dir($this->copy_destination_dir);
$dest_dir_writable = $dest_dir_exists ? is_writable($this->copy_destination_dir) : is_writable(dirname($this->copy_destination_dir));
// DEBUG: Check npm detection
$detected_npm = $this->find_npm();
$npm_exists = file_exists($detected_npm);
$npm_executable = $npm_exists ? is_executable($detected_npm) : false;
// Generate nonce URL
$rebuild_url = wp_nonce_url(
admin_url('admin-post.php?action=rebuild_tailwind'),
'rebuild_tailwind_nonce'
);
?>
<div class="wrap">
<h1>Tailwind CSS Handler</h1>
<?php settings_errors('tailwind_messages'); ?>
<div class="card">
<h2>Status</h2>
<table class="widefat">
<tbody>
<tr>
<td><strong>Node Modules:</strong></td>
<td>
<?php if ($node_modules_exists): ?>
<span style="color: green;">β Installed</span>
<?php else: ?>
<span style="color: red;">β Not installed</span>
<p><em>Run <code>npm install</code> in the plugin directory</em></p>
<?php endif; ?>
</td>
</tr>
<tr style="background: #f9f9f9;">
<td><strong>NPM Detection (DEBUG):</strong></td>
<td>
Detected path: <code><?php echo esc_html($detected_npm); ?></code><br>
File exists: <?php echo $npm_exists ? '<span style="color: green;">β Yes</span>' : '<span style="color: red;">β No</span>'; ?><br>
Is executable: <?php echo $npm_executable ? '<span style="color: green;">β Yes</span>' : '<span style="color: red;">β No</span>'; ?>
</td>
</tr>
<tr>
<td><strong>Compiled CSS (Build):</strong></td>
<td>
<?php if ($compiled_exists): ?>
<span style="color: green;">β Exists</span><br>
Last compiled: <?php echo esc_html($compiled_time); ?><br>
<small>Path: <code><?php echo esc_html($this->dist_file); ?></code></small>
<?php else: ?>
<span style="color: red;">β Not compiled</span>
<?php endif; ?>
</td>
</tr>
<tr>
<td><strong>Destination Directory:</strong></td>
<td>
<?php if ($dest_dir_exists): ?>
<span style="color: green;">β Exists</span>
<?php if ($dest_dir_writable): ?>
<span style="color: green;">β Writable</span>
<?php else: ?>
<span style="color: red;">β Not writable</span>
<?php endif; ?>
<?php else: ?>
<span style="color: orange;">β Will be created</span>
<?php endif; ?>
<br>
<small>Path: <code><?php echo esc_html($this->copy_destination_dir); ?></code></small>
</td>
</tr>
<tr>
<td><strong>Public CSS File:</strong></td>
<td>
<?php if ($copied_exists): ?>
<span style="color: green;">β Exists</span><br>
Last updated: <?php echo esc_html($copied_time); ?><br>
<small>Path: <code><?php echo esc_html($this->copy_destination_file); ?></code></small><br>
<small>URL: <code><?php echo esc_html($this->copy_destination_url); ?></code></small>
<?php else: ?>
<span style="color: red;">β Not copied</span><br>
<small>Will be created on build</small>
<?php endif; ?>
</td>
</tr>
</tbody>
</table>
</div>
<div class="card">
<h2>Build Tailwind CSS</h2>
<p>This will scan your theme files and generate optimized Tailwind CSS.</p>
<p>The CSS will be copied to <code><?php echo esc_html($this->copy_destination_file); ?></code></p>
<?php if (!$node_modules_exists): ?>
<p style="color: red;"><strong>Warning:</strong> Please run <code>npm install</code> in the plugin directory first.</p>
<?php endif; ?>
<p>
<a href="<?php echo esc_url($rebuild_url); ?>" class="button button-primary button-large">
β€ Rebuild Tailwind CSS
</a>
</p>
</div>
<div class="card">
<h2>Setup Instructions</h2>
<ol>
<li>Navigate to the plugin directory: <code><?php echo esc_html($this->plugin_dir); ?></code></li>
<li>Run: <code>npm install</code></li>
<li>Click "Rebuild Tailwind CSS" button above</li>
<li>Your theme can now use Tailwind utility classes</li>
</ol>
<h3>WP-CLI Command</h3>
<p>You can also rebuild via command line:</p>
<p><code>wp tailwind rebuild</code></p>
</div>
</div>
<?php
}
/**
* Handle rebuild request from admin
*/
public function rebuild_tailwind() {
check_admin_referer('rebuild_tailwind_nonce');
if (!current_user_can('manage_options')) {
wp_die(__('Unauthorized'));
}
$result = $this->run_build();
if ($result['success']) {
add_settings_error(
'tailwind_messages',
'tailwind_rebuilt',
'Tailwind CSS rebuilt and copied successfully!',
'success'
);
} else {
$error_message = 'Error: ' . $result['message'];
if (isset($result['output']) && !empty($result['output'])) {
$error_message .= '<br><pre style="background: #f0f0f0; padding: 10px; overflow-x: auto;">' .
esc_html($result['output']) . '</pre>';
}
add_settings_error(
'tailwind_messages',
'tailwind_error',
$error_message,
'error'
);
}
set_transient('settings_errors', get_settings_errors(), 30);
wp_redirect(add_query_arg('settings-updated', 'true', admin_url('tools.php?page=tailwind')));
exit;
}
/**
* WP-CLI rebuild command
*/
public function cli_rebuild($args, $assoc_args) {
WP_CLI::log('Building Tailwind CSS...');
$result = $this->run_build();
if ($result['success']) {
WP_CLI::success('Tailwind CSS built and copied successfully!');
if (!empty($result['output'])) {
WP_CLI::log('Output: ' . $result['output']);
}
} else {
WP_CLI::error('Build failed: ' . $result['message']);
}
}
/**
* Find npm executable path
*/
private function find_npm() {
// Check for manual override first
if ($this->npm_path_override && file_exists($this->npm_path_override) && is_executable($this->npm_path_override)) {
return $this->npm_path_override;
}
// Try common npm paths in order
$npm_paths = [
'/usr/local/bin/npm',
'/usr/bin/npm',
'/opt/homebrew/bin/npm',
'/home/' . get_current_user() . '/.nvm/versions/node/v18.0.0/bin/npm', // NVM path
'/root/.nvm/versions/node/v18.0.0/bin/npm', // Root NVM
];
foreach ($npm_paths as $path) {
if (file_exists($path) && is_executable($path)) {
return $path;
}
}
// Try to find npm via which command with expanded PATH
$which_result = @shell_exec('PATH=/usr/local/bin:/usr/bin:/bin:~/.nvm/versions/node/v18.0.0/bin which npm 2>/dev/null');
if (!empty($which_result)) {
$path = trim($which_result);
if (file_exists($path) && is_executable($path)) {
return $path;
}
}
// Last resort fallback
return 'npm';
}
/**
* Run the actual build process
*/
private function run_build() {
// Check if node_modules exists
if (!is_dir($this->plugin_dir . '/node_modules')) {
return [
'success' => false,
'message' => 'node_modules not found. Please run npm install first.'
];
}
// Ensure dist directory exists
$dist_dir = $this->plugin_dir . '/dist';
if (!is_dir($dist_dir)) {
if (!wp_mkdir_p($dist_dir)) {
return [
'success' => false,
'message' => 'Could not create dist directory: ' . $dist_dir
];
}
}
// Find npm executable
$npm = $this->find_npm();
// Run the build command with full npm path (no need for PATH if we have absolute path)
$command = sprintf(
'cd %s && %s run build 2>&1',
escapeshellarg($this->plugin_dir),
escapeshellarg($npm)
);
exec($command, $output, $return_var);
$output_string = implode("\n", $output);
// Check if build was successful
if ($return_var !== 0) {
return [
'success' => false,
'message' => 'Build command failed with exit code ' . $return_var . '. npm path: ' . $npm,
'output' => $output_string
];
}
// Verify the dist file was created
if (!file_exists($this->dist_file)) {
return [
'success' => false,
'message' => 'Build completed but dist file not found at: ' . $this->dist_file,
'output' => $output_string
];
}
// Copy the file
$copy_result = $this->copy_dist_file();
if (!$copy_result['success']) {
$copy_result['output'] = $output_string;
return $copy_result;
}
return [
'success' => true,
'message' => 'Build and copy completed successfully',
'output' => $output_string
];
}
/**
* Copies the compiled CSS to the assets directory
*/
private function copy_dist_file() {
// Verify source file exists
if (!file_exists($this->dist_file)) {
return [
'success' => false,
'message' => 'Source file does not exist: ' . $this->dist_file
];
}
// Ensure the destination directory exists using WordPress function
if (!wp_mkdir_p($this->copy_destination_dir)) {
return [
'success' => false,
'message' => 'Could not create target directory: ' . $this->copy_destination_dir . ' (Check permissions)'
];
}
// Verify directory is writable
if (!is_writable($this->copy_destination_dir)) {
return [
'success' => false,
'message' => 'Target directory is not writable: ' . $this->copy_destination_dir . ' (chmod 755 or 775)'
];
}
// Use WordPress filesystem API for better compatibility
require_once(ABSPATH . 'wp-admin/includes/file.php');
// Perform the copy operation
if (!copy($this->dist_file, $this->copy_destination_file)) {
$error = error_get_last();
return [
'success' => false,
'message' => 'Failed to copy file. ' . ($error ? $error['message'] : 'Unknown error')
];
}
// Verify the copy was successful
if (!file_exists($this->copy_destination_file)) {
return [
'success' => false,
'message' => 'Copy operation completed but file not found at destination'
];
}
return [
'success' => true,
'message' => 'File copied successfully to ' . $this->copy_destination_file
];
}
}
// Initialize the plugin
new TailwindHandler();
Disclaimer
I use Podman to create local instances of WordPress,so I don’t really pay much attention to the security side of things of this plugin and other plugin I have built.
Having said that, Despite my sites being fully static and there is no database, installation or server on the internet to hack.
I have built custom firewall rules to prevent bots from scanning theme, plugins and mu-plugins folders.