Show HN: WP-Easy, framework to build WordPress themes

3 weeks ago 1

A comprehensive guide for building modern WordPress themes using the WP-Easy framework—without the complexity of modern build tools.



WP-Easy is a framework designed for people who understand HTML, CSS, and JavaScript but want to build modern WordPress themes without dealing with complex build tools. There's no npm, no terminal commands, no webpack—just code, save, and refresh.

Key Features:

  • No build step required - Just code and refresh
  • Component-based architecture - Build reusable UI components
  • Single File Components - Combine PHP, HTML, SCSS, and JS in one file
  • Automatic asset loading - Styles and scripts are auto-enqueued
  • Live reload - See changes instantly during development
  • SCSS support - Write modern, nested styles
  • Flexible routing - Map URLs to templates and layouts

1. Install WP-Easy Plugin

Upload the WP-Easy plugin to your wp-content/plugins directory and activate it.

Create a new theme directory in wp-content/themes/your-theme-name/ with this basic structure:

Check out the WP-Easy Theme starter theme.

your-theme/ ├── index.php ├── functions.php ├── router.php ├── template.php ├── style.css ├── layouts/ │ └── default.php ├── templates/ ├── components/ ├── styles/ ├── scripts/ └── images/

3. Enable Pretty Permalinks

Go to WordPress Settings > Permalinks and select any option other than "Plain".

Set up your routes in router.php to map URLs to templates and layouts.


router.php - Entry point that determines routing

<? $routes = [ 'home' => '/', 'work' => '/work/', 'work-detail' => [ 'path' => '/work/:slug/', 'layout' => 'default', 'template' => 'work-detail' ] ]; return $routes;

/layouts/ - Layout files that wrap your page content

  • Purpose: Provide the overall page structure (header, footer, navigation)
  • Usage: Wraps templates with common page elements
  • Example: default.php contains header, main content area, and footer
  • Key function: Usesd for different site wide layout (such as conditonal header or footer, logged in chrome etc.)

/templates/ - Page-level templates for different routes

  • Purpose: Define the main content for each page/route
  • Usage: Contains the specific content for home, blow, about pages, etc.
  • Example: home.php for homepage, work-detail.php for work pages
  • Key function: Uses use_component() to build pages from reusable pieces

/components/ - Reusable UI components, loaded using use_component()

  • Purpose: Build modular, reusable pieces of your site
  • Usage: Header, footer, buttons, image blocks, etc.
  • Example: header.php, work-block.php, wp-image.php
  • Key function: Accept props via $args and render specific UI elements

functions/utils.php - Your theme functions would go in here.

  • /styles/ - Global SCSS/CSS files (auto-loaded)
  • /scripts/ - JavaScript files (auto-loaded)
  • /images/ - Static images and SVGs. SVG's loaded using use_svg()

The router is the heart of WP-Easy's template system. It's the entry point that determines which layout and template to use for each URL. Define your routes in router.php:

<? $routes = [ 'home' => '/', 'work' => '/work/', 'work-detail' => [ 'path' => '/work/:slug/', 'layout' => 'default', 'template' => 'work-detail' ], 'about' => '/about/', ]; return $routes;

Simple Routes:

'home' => '/', // Maps to /templates/home.php 'work' => '/work/', // Maps to /templates/work.php

Advanced Routes:

'work-detail' => [ 'path' => '/work/:slug/', // URL pattern with parameter 'layout' => 'alternate', // Use /layouts/alternate.php 'template' => 'work-detail' // Use /templates/work-detail.php ]

Use :parameter syntax for dynamic routes:

  • :slug - Post slug
  • :id - Post ID
  • :page - Page number

These parameters aren't used for anything currently, it's simply for human readability.

How a request leads to a built page

  1. Router (router.php) - Determines which layout and template to use
  2. Layout (/layouts/default.php) - Wraps the content with header, footer, etc.
  3. Template (/templates/home.php) - Contains the main page content
  4. Components - Reusable pieces used within templates (and in layouts or inside other components too!)

If no route matches, WP-Easy falls back to the theme's /index.php file.


Single File Components (SFCs)

SFCs allow you to combine PHP logic, HTML markup, SCSS styles, and JavaScript in a single .php file.

<script src="https://cdn.jsdelivr.net/npm/chart.js"></script> </head> <template> <div class="component"> <h2><?= $args['title']; ?></h2> </div> </template> <style> /* SCSS/CSS styles */ .component { background: red; .title { color: blue; } } </style> <script> // JavaScript for this component $('.component').click(function() { // jQuery is available as $() }); </script>"><? // PHP logic at the top $args = set_defaults($args, ['title' => 'Default Title']); ?> <head> <!-- Optional tag: Third-party stylesheets or scripts that should go in the head--> <script src="https://cdn.jsdelivr.net/npm/chart.js"></script> </head> <template> <!-- Your HTML markup --> <div class="component"> <h2><?= $args['title']; ?></h2> </div> </template> <style> /* SCSS/CSS styles */ .component { background: red; .title { color: blue; } } </style> <script> // JavaScript for this component $('.component').click(function() { // jQuery is available as $() }); </script>
  • PHP Block - Logic and data preparation
  • <head> Block - Third-party stylesheets/scripts
  • <template> Block - HTML markup
  • <style> Block - Scoped SCSS/CSS
  • <script> Block - Component-specific JavaScript

Layouts wrap your page content and provide the overall page structure.

Default Layout (/layouts/default.php)

<? $header_size = 'normal'; $show_footer = true; switch (use_route_name()) { case 'work-detail': $header_size = 'small'; $show_footer = false; break; } ?> <template> <? use_component('header', ['size' => $header_size]); ?> <main id="content"> <? use_outlet(); ?> </main> <? if ($show_footer) : ?> <? use_component('footer'); ?> <? endif; ?> </template> <style> /* Layout-specific styles */ #content { min-height: 100vh; } </style>
  • use_outlet() - Renders the current template
  • use_component() - Includes header, footer, or other components
  • Route-based logic - Different layouts for different pages
  • Scoped styles - Layout-specific CSS

Templates represent the main content for each route.

Basic Template (/templates/home.php)

<template> <main class="template-home"> <? use_component('hero-section'); ?> <section class="featured-work"> <? foreach (use_children() as $post) : ?> <? use_component('work-block', [ 'title' => $post->title, 'url' => $post->url, 'image_id' => $post->thumbnail_id ]); ?> <? endforeach; ?> </section> </main> </template> <style> .template-home { .featured-work { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 2rem; } } </style>
  • Post data access - Use $post object with extensions
  • Component composition - Build pages from reusable components
  • Scoped styles - Template-specific CSS
  • Helper functions - Use use_children(), use_posts(), etc.

Components are reusable UI pieces that can be used across templates and layouts.

Basic Component (/components/work-block.php)

<? $args = set_defaults($args, [ 'title' => '', 'url' => '', 'image_id' => 0, 'class' => '' ]); ?> <template> <article class="work-block <?= $args['class']; ?>"> <a href="<?= esc_url($args['url']); ?>"> <? use_component('wp-image', [ 'image_id' => $args['image_id'], 'class' => 'work-image' ]); ?> <h3 class="work-title"><?= esc_html($args['title']); ?></h3> </a> </article> </template> <style> .work-block { position: relative; overflow: hidden; .work-image { transition: transform 0.3s ease; } &:hover .work-image { transform: scale(1.05); } } </style>
  • Use set_defaults() - Set default values for props
  • Scoped styles - Component-specific CSS
  • Reusable props - Design for flexibility

WP-Easy provides powerful SCSS support with automatic compilation and loading.

  • SCSS compilation - Automatic SCSS to CSS conversion
  • Global imports - Files in /styles/global/ are auto-imported
  • Component scoping - Styles are scoped to components
  • Media queries - Use $lt-phone, $lt-tablet variables
  • CSS variables - Use var(--color-primary) for theming

Global Styles (/styles/main.scss)

// Variables $primary-color: #ff6b6b; $font-primary: 'Helvetica', sans-serif; // Mixins @mixin button-style { padding: 1rem 2rem; border: none; border-radius: 4px; cursor: pointer; } // Global styles body { font-family: $font-primary; line-height: 1.6; } .button { @include button-style; background: $primary-color; color: white; }
<style> .component { background: var(--color-primary); .title { font-size: 2rem; margin-bottom: 1rem; } // Responsive design @media #{$lt-phone} { .title { font-size: 1.5rem; } } } </style>

WP-Easy automatically handles two special CSS files for WordPress-specific areas:

Login Page Styling (/styles/login.css)

Customize the WordPress login page appearance:

body.login { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); } #login h1 a { background-image: url(../images/logo.svg); background-position: center center; background-size: auto; }

Admin Area Styling (/styles/admin.css)

Customize the WordPress admin dashboard:

#wpadminbar { background: #2c3e50; }
  • Automatic loading - Files are automatically enqueued when present
  • Login page - login.css only loads on /wp-login.php
  • Admin area - admin.css only loads in the WordPress admin
  • Excluded from frontend - These files don't load on your public website
  • Favicon integration - Admin CSS automatically includes your theme's favicon

JavaScript files are automatically loaded from the /scripts folder and can be modular.

Main Script (/scripts/main.js)

// Site-wide JavaScript $(document).ready(function() { // Initialize components initNavigation(); initScrollEffects(); }); function initNavigation() { $('.hamburger').click(function() { $('.menu-tray').toggleClass('is-open'); }); } function initScrollEffects() { $(window).scroll(function() { if ($(window).scrollTop() > 100) { $('.header').addClass('is-scrolled'); } else { $('.header').removeClass('is-scrolled'); } }); }
  • jQuery included - $ is available globally
  • ES modules - Modern JavaScript module system
  • Auto-loading - All files in /scripts/ are loaded
  • Component scripts - Scoped to specific components
  • No document.ready needed - Component <script> blocks run when component is rendered.
<script> // Component-specific JavaScript $('.work-block').hover( function() { $(this).find('.work-image').addClass('is-hovered'); }, function() { $(this).find('.work-image').removeClass('is-hovered'); } ); </script>

Component Isolation Best Practices

✅ Good - Component stays isolated:

<script> // Only manipulate elements within this component $('.work-block').click(function() { $(this).toggleClass('is-expanded'); }); </script>

❌ Avoid - Don't manipulate other parts of the site:

<script> // Don't do this - affects other components $('.header').addClass('work-page'); $('.footer').hide(); </script>

✅ For site-wide functionality, use /scripts/main.js on use a Template or Layout <script> block

// /scripts/main.js - Site-wide functionality $(document).ready(function() { // Global navigation, scroll effects, etc. initNavigation(); initScrollEffects(); });

WP-Easy automatically converts component scripts to ES modules:

<script> // This becomes an ES module automatically export function initWorkBlock() { $('.work-block').click(function() { // Component logic here }); } </script>

Module Features:

  • Automatic conversion - Component scripts become ES modules
  • Dependency management - Modules can import/export functions
  • Performance - Only loads when component is used
  • Isolation - Component scripts don't conflict with each other

Module Names:

  • Component modules: {type}-{filename} (e.g., components-work-block, templates-home)
  • Utility modules: utils-{filename} (e.g., utils-clamp, utils-delay)
  • Main module: main (site-wide scripts)

Importing Between Modules:

// In a component script <script> import { clamp } from 'utils-clamp'; import { delay } from 'utils-delay'; export function initWorkBlock() { $('.work-block').click(function() { const value = clamp(0, 100, 50); // From utils-clamp delay(1000).then(() => { // From utils-delay }); }); } </script>

Exporting from Components:

<script> // Export functions for other modules to use export function showWorkDetails() { $('.work-details').slideDown(); } export function hideWorkDetails() { $('.work-details').slideUp(); } </script>

Loading Custom JavaScript Libraries

Use the <head> tag to load third-party libraries that need to be available globally:

<? // Component that uses Chart.js $args = set_defaults($args, ['chart_data' => []]); ?> <head> <script src="https://cdn.jsdelivr.net/npm/chart.js"></script> </head> <template> <div class="chart-container"> <canvas id="myChart"></canvas> </div> </template> <script> // Chart.js is now available globally const ctx = $('#myChart').get(0).getContext('2d'); const chart = new Chart(ctx, { type: 'bar', data: <?= json_encode($args['chart_data']); ?> }); </script>

Common Use Cases:

  • Analytics scripts - Google Analytics, Facebook Pixel
  • Maps - Google Maps, Mapbox
  • Charts - Chart.js, D3.js
  • UI libraries - Bootstrap, Foundation

WP-Easy makes it easy to include and customize SVGs.

// Will render `/images/logo.svg` with supplied attributes. <? use_svg('logo', ['class' => 'site-logo', 'width' => 120]); ?>

SVG with Custom Attributes

<? use_svg('icon-arrow', [ 'class' => 'arrow-icon', 'width' => 24, 'height' => 24, 'fill' => 'currentColor' ]); ?>
  • Automatic loading - SVGs from /images/ directory
  • Custom attributes - Add classes, dimensions, etc.
  • Security - Strips XML declarations and unwanted tags
  • Performance - Inline SVGs for better performance

WP-Easy supports font loading using Webfontloader.

Font config (/scripts/fonts.js)

  • Local fonts - Host fonts in your theme
  • Google Fonts - Easy integration
  • Adobe Fonts - Creative Cloud integration
  • Performance - Optimized loading

See Webfontloader for explanation on how to load all the supported font types.

// Load local fonts WebFont.load({ custom: { families: ['CustomFont'], urls: ['/fonts/custom-font.css'] } });

WP-Easy includes built-in hot reload for instant development feedback.

  1. Enable WordPress Debug Mode

    // In wp-config.php define('WP_DEBUG', true); define('WP_DEBUG_LOG', true);
  2. That's it! Hot reload automatically activates when you're logged in as an administrator.

  • File monitoring - Watches for changes in theme files
  • Instant refresh - Browser refreshes automatically
  • Child theme support - Works with both parent and child themes
  • All file types - PHP, SCSS, CSS, and JavaScript
  • All theme subdirectories
  • Excludes: /node_modules, /.git, /vendor, /images

Hot reload not working?

  • Ensure WP_DEBUG is set to true
  • Check browser console for errors
  • Verify you're logged in as administrator

WP-Easy provides powerful helper functions for common tasks.

use_route_name() or get_route_name - Get current route name

if (use_route_name() === 'home') { // Home page logic }

use_component($name, $props) - Render component with props

use_component('work-block', [ 'title' => 'Project Title', 'url' => '/work/project/', 'image_id' => 123 ]);

use_children($args) - Get child pages of current page, ordered by menu_order

  • Args: Standard WP_Query arguments with sensible defaults
  • Defaults: post_type: 'any', post_parent: current_post_id, posts_per_page: -1, order: 'ASC', orderby: 'menu_order'
<? foreach (use_children() as $post) : ?> <h3><?= $post->title; ?></h3> <? endforeach; ?> <? foreach (use_children(['post_type' => 'work', 'posts_per_page' => 6]) as $post) : ?> <h3><?= $post->title; ?></h3> <? endforeach; ?>

use_posts($args) - Get posts for the current page, with pagination links also.

  • Args: Standard WP_Query arguments with sensible defaults
  • Defaults: post_type: 'post', posts_per_page: get_option('posts_per_page'), paged: current_page, orderby: 'date', order: 'DESC'
<? $posts_data = use_posts(); foreach ($posts_data->posts as $post) : ?> <article><?= $post->title; ?></article> <? endforeach; ?> <? $work_posts = use_posts(['post_type' => 'work', 'posts_per_page' => 12]); foreach ($work_posts->posts as $post) : ?> <article><?= $post->title; ?></article> <? endforeach; ?> <? if ($posts_data->next_posts_url) : ?> <a href="<?= $posts_data->next_posts_url; ?>">Next Page</a> <? endif; ?>

use_adjacent($post_id, $direction) - Get next/previous page/post

$next_post = use_adjacent($post->ID, 'next'); $prev_post = use_adjacent($post->ID, 'previous');

use_svg($name, $attrs) - Render SVG with attributes. $name is the filename of SVG file inside /images/.

use_svg('logo', ['class' => 'header-logo', 'width' => 120]);

set_defaults($args, $defaults) - Set default values for a component's $args.

$args = set_defaults($args, [ 'title' => 'Default Title', 'class' => 'default-class' ]);

set_attribute($name, $condition) - Conditional HTML attributes

<button <?= set_attribute('disabled', !$is_enabled); ?>> Submit </button>
<button <?= set_attribute('class="is-opened"', $is_opened); ?>> Close </button>

WP-Easy extends the WordPress $post object with useful shortcuts.

$post->id - Post ID (same as $post->ID)

$post->url - Post permalink

<a href="<?= $post->url; ?>">Read More</a>

$post->title - Filtered post title

<h1><?= $post->title; ?></h1>

$post->content - Filtered post content

<div class="content"><?= $post->content; ?></div>

$post->excerpt - Post excerpt, will return an auto excerpt if user generated one is empty.

<p><?= $post->excerpt; ?></p>

$post->thumbnail_id - Featured image ID

<? use_component('wp-image', [ 'image_id' => $post->thumbnail_id, 'class' => 'featured-image' ]); ?>

ACF (Advanced Custom Fields) fields are automatically available as shortcuts on the $post object:

// If you have ACF fields like 'video_url', 'director_credit', 'gallery_images' <? if ($post->video_url) : ?> <video src="<?= $post->video_url; ?>" controls></video> <? endif; ?> <? if ($post->director_credit) : ?> <p>Director: <?= $post->director_credit; ?></p> <? endif; ?> <? foreach ($post->gallery_images as $image) : ?> <? use_component('wp-image', [ 'image_id' => $image['id'], 'class' => 'gallery-image' ]); ?> <? endforeach; ?>
// In a template <article class="post"> <h2><a href="<?= $post->url; ?>"><?= $post->title; ?></a></h2> <? if ($post->thumbnail_id) : ?> <? use_component('wp-image', [ 'image_id' => $post->thumbnail_id, 'class' => 'post-image' ]); ?> <? endif; ?> <div class="excerpt"><?= $post->excerpt; ?></div> </article>

<template> <section class="work-grid"> <? foreach (use_children() as $post) : ?> <article class="work-item"> <a href="<?= $post->url; ?>"> <? use_component('wp-image', [ 'image_id' => $post->thumbnail_id, 'class' => 'work-image' ]); ?> <h3><?= $post->title; ?></h3> </a> </article> <? endforeach; ?> </section> </template>
<template> <nav class="main-nav"> <? wp_nav_menu([ 'menu_class' => 'nav-menu', 'container' => false, 'menu' => 'primary' ]); ?> </nav> </template>
<template> <div class="blog-posts"> <? $posts_data = use_posts(); foreach ($posts_data->posts as $post) : ?> <article class="blog-post"> <h2><a href="<?= $post->url; ?>"><?= $post->title; ?></a></h2> <div class="excerpt"><?= $post->excerpt; ?></div> </article> <? endforeach; ?> <? if ($posts_data->next_posts_url) : ?> <a href="<?= $posts_data->next_posts_url; ?>" class="load-more">Load More</a> <? endif; ?> </div> </template>
<template> <div class="gallery"> <? foreach ($post->gallery_images as $image) : ?> <? use_component('wp-image', [ 'image_id' => $image['id'], 'class' => 'gallery-image' ]); ?> <? endforeach; ?> </div> </template>
<template> <div class="content"> <? if ($post->video_url) : ?> <video src="<?= $post->video_url; ?>" controls></video> <? elseif ($post->thumbnail_id) : ?> <? use_component('wp-image', [ 'image_id' => $post->thumbnail_id, 'class' => 'featured-image' ]); ?> <? endif; ?> <div class="text-content"><?= $post->content; ?></div> </div> </template>

For more examples and advanced usage, check out the WP-Easy Theme starter theme.

Read Entire Article