A custom, performance-first WordPress theme. Built to be clean, accessible, and easy to maintain.
Architectural decisions and rationale: see ARCHITECTURE.md. Read it before forking the starter for a new project, or before changes that affect the boundary between theme, plugin, and content.
- Custom WordPress theme with purpose-built page templates and content flows.
- Fast-loading UI with a minimal JavaScript footprint.
- Responsive navigation and interactive elements.
This theme is built without a page builder (Elementor, Divi, etc.). That is a deliberate choice with trade-offs.
Building directly in PHP, Sass, and Tailwind gives full control over the markup and the asset pipeline. Designs can be more elaborate, layouts can be tuned per page, and the resource footprint stays small because nothing is loaded that isn't used. Custom page templates are used as one-offs — applied directly to specific pages — rather than as reusable templates in the traditional WordPress sense. This is the most direct way to apply bespoke design on a per-page basis. Gutenberg is reserved for basic content pages where editor flexibility matters more than design fidelity.
The trade-off: this approach assumes a developer maintains the site and can run the build environment. It suits projects with custom design requirements and the budget to support them. A page-builder approach is a better fit for projects optimized for non-developer editing or tighter budgets.
The theme performs in the top percentiles across standard web performance metrics. The main contributors:
- Minimal front-end JavaScript footprint.
- Optimized build pipeline with Vite, PostCSS, and Tailwind's purge step.
- Static, hand-authored markup rather than page-builder output.
- WordPress (custom theme)
- Vite, Sass, Tailwind, PostCSS
theme.json(block editor presets + global styles)- Forminator (forms)
- Yoast SEO / Rank Math (SEO — bring your own; see SEO)
.
├── src/ # Source files (edit here)
├── dist/ # Compiled output (do not edit)
│ ├── assets/
│ └── tailwind.css
├── icons/ # Inline-SVG icons (one .svg per icon)
├── inc/ # PHP modules wired by functions.php
│ ├── theme-setup.php # Theme supports, menus, textdomain
│ ├── enqueue.php # Asset enqueue (Vite dev / dist build)
│ ├── preload.php # Critical font preload <link> tags
│ ├── icons.php # theme_icon() / theme_the_icon() helpers
│ ├── widgets.php # Sidebar registration
│ ├── branding.php # Login screen overrides
│ ├── cpt.php # Custom post type scaffold (commented)
│ ├── attachments.php # Strip auto-titles from media uploads
│ ├── images.php # Image-quality + SVG-upload scaffold
│ ├── heartbeat.php # Throttle WP heartbeat
│ ├── security.php # XML-RPC, login errors, REST hardening
│ ├── comments.php # Comments disabled globally
│ ├── analytics.php # GA4 scaffold (off until constant defined)
│ └── bloat.php # Strip emoji/oembed/generator/etc.
├── page-*.php # Custom page templates (one-offs)
├── functions.php # Module manifest only — `require_once` calls
├── theme.json # Block editor presets + global styles
├── vite.config.js
└── tailwind.config.cjs
functions.php is a manifest of require_once calls. To disable a module, comment out its line — every file is self-contained.
npm installnpm run dev:all- Set
CUSTOM_WP_VITE_DEVto true inwp-config.phpto load the dev server.
define('CUSTOM_WP_VITE_DEV', true);
npm run build- WordPress enqueues:
dist/tailwind.cssdist/assets/main.cssdist/assets/main.js
The starter is meant to be cloned. There are two slug-coupled values that must be updated together when you fork it; missing either causes hard-to-spot breakage (translations stop loading, package managers complain).
The two slug-coupled values:
- The theme directory name in
wp-content/themes/. - The text-domain (
'mgn'literal, used by every__(),_e(),esc_html_e(),esc_attr_e()call).
Substitute mgn → <newslug> everywhere below. Use a project-wide find/replace tool, or sed. The slug should match the new theme directory name.
Required:
- Rename the theme directory:
wp-content/themes/mgn→wp-content/themes/<newslug>. - Update
style.cssTheme Name header (currentlyBarebones Starter Theme) to reflect the project. - Find/replace
'mgn'→'<newslug>'across all.phpfiles (text-domain in i18n calls). Affected files: every template, every partial, everyinc/*.php. ~15 files. - Update
inc/theme-setup.phpline 7:load_theme_textdomain( 'mgn', ... )→load_theme_textdomain( '<newslug>', ... ). - Update
package.json"name": "mgn"→"name": "<newslug>". - Update
composer.json"name": "mgn/theme"→"name": "<newslug>/theme". - Update
phpcs.xml.dist<element value="mgn"/>→<element value="<newslug>"/>(text-domain whitelist). - Run
npm installto regeneratepackage-lock.jsonagainst the new package name. - Run
composer installto regeneratecomposer.lockagainst the new package name. - Run
npm run buildand reload the site. Confirm at least one translatable string still appears (translation lookup is silent fallback when the text-domain is wrong, so confirm visually or by adding a.mofile for the new domain).
Optional (project polish, not breakage-causing):
- Update
AGENTS.mdreferences (paths, examples). - Update
README.mdandARCHITECTURE.mdto reflect the new project name (or delete and rewrite for the project). - Update
screenshot.webpwith the new project's hero/preview. - Update
partials/logo.phpif you're not usingthe_custom_logo()from the Customizer. - Update
footer.phpcopyright text if you're not usingbloginfo('name'). - Rename example plugin names in ARCHITECTURE.md (
mgn-events,mgn-projects) to your project's pattern.
If you're confident, you can do most of the required substitutions in one pass from the theme root:
# Replace text-domain in all PHP, plus package.json, composer.json, phpcs.xml.dist
find . -type f \( -name '*.php' -o -name 'package.json' -o -name 'composer.json' -o -name 'phpcs.xml.dist' \) \
! -path './node_modules/*' ! -path './vendor/*' ! -path './dist/*' \
-exec sed -i 's/mgn/<newslug>/g' {} +Review the diff before committing — sed will catch every occurrence of mgn including inside comments, brand names, and example references. Inspect for over-replacement.
WordPress conventions require literal text-domains. Storing 'mgn' in a variable and passing it everywhere works against WP's static analysis tools (PHPCS i18n sniffs flag non-literal text-domains as errors). Hard-coded literals are the standard.
The trade-off: ~15 files to find/replace once per fork. Document the slug in your project README so future-you remembers what it is.
A theme.json ships with the theme, defining the color palette, font families, layout sizes, and link styling that the block editor uses. The same compiled stylesheets enqueued on the front-end are registered as editor styles via add_theme_support('editor-styles') + add_editor_style(), so blocks render with your design tokens both in the editor and on the live site.
If you change a token in src/styles/_layout.scss (e.g., a brand color), update the matching value in theme.json so the editor presets stay in sync. They are intentionally separate sources because Sass cannot be evaluated by theme.json's loader and vice versa.
Color picker is restricted to the theme palette (color.custom: false) so editor users can't introduce off-brand colors. To allow custom colors, flip to true in theme.json.
Two widget areas are registered in theme_setup():
- Primary Sidebar (
primary) — callget_sidebar()from any template that should display it. Thesidebar.phptemplate is in place and self-hides if no widgets are assigned. - Footer Widgets (
footer) — calldynamic_sidebar('footer')fromfooter.phpif you want a widgetized footer block.
Build CPTs as plugins, not in the theme. Custom post types register data that should outlive the active theme. A CPT registered in the theme disappears the moment a new theme is activated — existing posts become unqueryable ghosts in the database, admin can't see them, and front-end URLs 404. The client loses access to their content.
The recommended pattern:
| Layer | Lives in | Contains |
|---|---|---|
| Data + admin | Plugin (e.g., mgn-events, mgn-projects) |
register_post_type, taxonomies, ACF / Carbon Fields registration, admin column tweaks, pre_get_posts filters, REST/GraphQL exposure |
| Presentation | This theme (or a project-specific fork) | single-{type}.php, archive-{type}.php, partials/{type}-card.php, type-specific SCSS, block patterns |
A typical events plugin structure mirrors this theme's inc/ pattern: mgn-events.php bootstrap + inc/cpt.php, inc/taxonomy.php, inc/fields.php, inc/rest.php, acf-json/ for field-group exports. Same conventions, separate distribution.
Headless implications. With CPTs in a plugin and 'show_in_rest' => true on registration, the data layer is queryable from any front-end (PHP templates, Next.js, Astro, etc.). The plugin doesn't care which renderer consumes it. Re-skin or replace the front-end without touching the data.
The scaffold in inc/cpt.php. A commented register_post_type example lives there for two narrow cases: throwaway prototyping, or a CPT that's genuinely theme-locked and will never outlive the build. For anything production, build a plugin. The file's header comment carries the same warning.
inc/security.php ships defense-in-depth defaults:
- XML-RPC disabled via
xmlrpc_enabledfilter — common brute-force surface, rarely needed by modern sites. Re-enable for Jetpack or remote publishing by removing the filter. - Login errors masked to a generic "Invalid login. Please try again." — default WP returns "the username
adminis incorrect" / "the password is wrong", both leaking information about whether a username exists. - REST
/wp/v2/usersendpoints removed — by default these are publicly readable and expose every author's username. Strips/usersand/users/(?P<id>[\d]+)fromrest_endpoints. ?author=Nenumeration blocked — WordPress redirects/?author=1to/author/{username}/, leaking the slug. The starter redirects any?authorquery on the front-end to home.
Comment out specific filters in inc/security.php if you need any of these behaviors back (e.g., Jetpack needs XML-RPC, the WP REST API users endpoint may be needed for headless setups).
Comments are disabled globally by inc/comments.php. The module:
- Forces
comments_open()andpings_open()to false everywhere. - Empties any existing comment lists from the front-end.
- Removes comment support from the
postandpagepost types. - Hides the Comments admin menu, admin bar item, and redirects
edit-comments.php.
To re-enable comments site-wide, set define('THEME_ENABLE_COMMENTS', true); in wp-config.php, or comment out the require_once for inc/comments.php in functions.php. The comments.php template is in place either way — it'll start rendering as soon as comments are re-enabled.
inc/images.php tunes two upload defaults:
big_image_size_thresholdraised from 2560 → 3000px, so retina-density source images survive their initial scale-down pass.jpeg_qualityraised from 82 → 85, slightly higher quality at minor file-size cost.
LCP hint. The first attachment image rendered inside the main loop on a singular page is automatically promoted to loading="eager" + fetchpriority="high". This overrides WordPress's auto-heuristic, which otherwise can mis-assign priority to the wrong early image (logo, avatar, nav icon) on a layout where those appear above the hero.
The filter only affects images flowing through wp_get_attachment_image() / the_post_thumbnail() / wp_get_attachment_image_attributes. Templates that render hand-written <img> tags as the LCP element should set loading="eager" and fetchpriority="high" on the tag manually.
For hero/banner sections at the top of a template, prefer an <img> positioned absolutely over a CSS background-image. The <img> route gets the LCP filter, srcset/sizes, alt text, and the browser's preload scanner — all for free. CSS background-image skips every one of those.
Recommended pattern:
<section class="hero relative">
<?php
echo wp_get_attachment_image(
get_post_thumbnail_id(),
'full',
false,
array(
'class' => 'hero-bg',
'sizes' => '100vw',
)
);
?>
<div class="hero-content relative z-10">
<h1><?php the_title(); ?></h1>
</div>
</section>.hero {
position: relative;
min-height: 60vh;
overflow: hidden;
}
.hero-bg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
z-index: -1;
}
.hero-content {
position: relative;
z-index: 1;
}The LCP filter in inc/images.php auto-applies loading="eager" + fetchpriority="high" because the call goes through wp_get_attachment_image() on a singular page in the main loop. No template-level work needed.
When CSS background-image is unavoidable (parallax effects, layered overlays, art-direction tricks an <img> can't reproduce), the browser only discovers the URL after parsing the CSS file — that's a delayed LCP. Emit a preload hint:
add_action( 'wp_head', function () {
if ( ! is_singular() ) return;
$hero_id = get_post_thumbnail_id();
if ( ! $hero_id ) return;
$url = wp_get_attachment_image_url( $hero_id, 'full' );
if ( ! $url ) return;
printf(
'<link rel="preload" as="image" href="%s" fetchpriority="high">',
esc_url( $url )
);
}, 1 );Rule of thumb: if the hero is purely visual with content overlaid, use the <img> pattern. Use the preload hint only when the design genuinely needs CSS-only background effects.
SVG uploads are blocked by default. SVGs can carry inline JavaScript that runs when the file is rendered — uploading an unsanitized SVG is an XSS vector. inc/images.php ships a commented-out upload_mimes filter for SVG, with explicit instructions: install Safe SVG (10up plugin) for automatic sanitization, or composer require enshrined/svg-sanitize and run sanitization in a wp_handle_upload_prefilter. Do not uncomment the bare allow-list without one of those paths in place.
The theme uses inline SVG icons, not an icon font. Files live in icons/ at the theme root, one .svg per icon. Render via the helper in inc/icons.php:
<?php theme_the_icon( 'angle-up' ); ?> // echoes
<?php echo theme_icon( 'angle-up', array( // returns; overrides defaults
'class' => 'icon icon-angle-up big',
'aria-label' => 'Scroll up',
'aria-hidden' => false,
) ); ?>Defaults applied to every icon: class="icon icon-<name>", aria-hidden="true", focusable="false". Override per call via the $args array.
Why inline SVG instead of an icon font:
- No font-loading FOUT — icons paint as soon as HTML parses.
- ~150 bytes per icon vs. hundreds of KB for a webfont's full glyph table — only ship what you use.
fill="currentColor"inherits text color via CSS naturally.- Better accessibility (SVGs play more cleanly with screen readers than
::beforeicon-font glyphs). - No build step for icons; no copy script; no theme-name-hardcoded font path.
Adding an icon:
- Source from FontAwesome free SVG download, lucide.dev, heroicons.com, tabler.io/icons, or any other SVG library.
- Save as
icons/<name>.svg(kebab-case). - Open and scrub editor garbage (
<defs>you don't need,<metadata>, weird namespaces, hardcoded colors). Setfill="currentColor"on the<svg>or<path>. Drop anywidth/heightattributes — let CSS size them. - Call
theme_the_icon( '<name>' )in your template.
The decision rule: "if a user bumps their browser font-size to 24px, should this thing get proportionally bigger?" Yes → rem. No, this is a fixed UI detail → px. It should track the element's type → em.
rem is the default for typography, spacing, padding, margins, max-widths, gap, top/right/bottom/left offsets — anything that should scale with user font-size preferences. This matches Tailwind's spacing scale (p-4 = 1rem padding), so SCSS and Tailwind read the same way.
px is correct for fixed UI details:
- Borders and outlines (
1px,2pxhairlines) — fractional rems for borders look weird and don't survive sub-pixel rendering. - Box-shadow offsets and blur radii.
- Outline-offset, focus-ring offset.
- SVG icon strokes.
- Specific pixel-precise dimensions (hamburger menu line width, drawer-stripe heights, fixed caps inside
min(320px, 88vw)). - Media query breakpoints (Tailwind uses px; match it).
- The
html { font-size: 17px }anchor intailwind.css— this is the rem base; don't change to rem.
em for component-scoped scaling. When something should track the element's own font-size rather than the root:
.button {
padding: 0.5em 1em; // tracks the button's text size
border-radius: 0.25em;
}A .button.big { font-size: 1.5rem } then auto-scales padding and radius without a separate rule. Right tool for buttons, badges, chips, anything with size variants. Use sparingly outside that case — em compounds when nested.
%, vw, dvh, fr, minmax() for layout sizing where fluid behavior matters. Prefer dvh over vh for full-viewport heights — dvh (dynamic viewport height) handles iOS Safari's address-bar shrinkage correctly.
Antipatterns:
- px for typography. Locks out user font-size scaling entirely. Always rem.
- Mixing px and rem inconsistently for the same property type across components — predictability suffers.
- Manual rem-to-px math. The html base is
17px, not16px. If you need exactly 16px, write16px; if you want proportional scaling, accept the rem result.
The same rules in scannable form for agents: see AGENTS.md → Unit Conventions.
Several optimizations are baked in by default:
- Font preload — inc/preload.php emits
<link rel="preload">tags for the body and heading fonts atwp_headpriority 1. Without this, the browser only discovers@font-faceURLs after parsing the main stylesheet — preloading saves 100-300ms of LCP on first visit. The list is curated (Lato 400 + Marcellus 400 by default); add new entries when introducing above-the-fold fonts, remove unused ones — preloading bytes you don't render is net negative. - Tailwind minification —
postcss.config.cjsrunscssnanoon the production Tailwind build. Dev mode uses the Tailwind CLI directly and bypasses PostCSS, so dev iteration stays fast. - JS deferred —
theme-mainenqueues with'strategy' => 'defer'and is an ES module (deferred by spec). - CSS conditional load — block library CSS (~30KB) only loads on singular pages with block content; archives/search/PHP-only templates skip it. See Stripped WP Defaults.
- LCP image hint — first
wp_get_attachment_image()on singular pages auto-promotes toloading="eager"+fetchpriority="high". See Image Handling. - Inline SVG icons — saves ~250-320KB vs the FontAwesome webfont it replaced. See Icons.
- Bloat strip — emoji JS, generator meta, oEmbed, classic-theme-styles, dns-prefetch hints, etc. See Stripped WP Defaults.
inc/preload.php lists the woff2 files to preload. The filenames assume Vite isn't hashing assets (it's configured [name][extname] with no [hash] in vite.config.js). After running npm run build, the file at dist/assets/lato-latin-400-normal.woff2 is the file the preload tag points at — keep them in sync.
When adding a new font weight or family:
- Run
npm run buildand checkdist/assets/for the actual filename produced. - Add it to the
$fontsarray ininc/preload.phponly if it's used above the fold. - Don't preload unused weights — wastes bandwidth and competes with critical resources.
inc/analytics.php ships a Google Analytics 4 (gtag.js) scaffold. Active by default but no-ops unless THEME_GA_MEASUREMENT_ID is defined in wp-config.php:
define( 'THEME_GA_MEASUREMENT_ID', 'G-XXXXXXXXXX' );Once defined, GA fires on every front-end page load except:
- Vite dev mode (
CUSTOM_WP_VITE_DEVtrue) — no analytics from local development. - Logged-in users — admins clicking around shouldn't pollute stats.
Performance details:
- Emits
<link rel="preconnect">togoogletagmanager.comandgoogle-analytics.comso TLS handshakes start before the gtag.js fetch fires (~100-300ms saved on first event). - Loads
gtag.jswithasyncso it doesn't block parsing or rendering. - Hooked at
wp_headpriority 5 — runs before stylesheets and font preloads, giving preconnect the maximum head start.
To use a different analytics provider (Plausible, Fathom, Matomo, Mixpanel), this file is a template — same shape, swap the URLs and snippet for the provider's docs.
inc/heartbeat.php reduces WordPress heartbeat traffic:
- Throttle
intervalfrom 15 seconds (editor default) to 60 seconds site-wide. - Disable the heartbeat script entirely on the front-end (admin still uses it for autosave and post-locking).
The theme dequeues several WordPress defaults that aren't useful here. All live in functions.php under "Strip WordPress Bloat" — comment out individual add_action/add_filter lines to restore any.
| Stripped | Why |
|---|---|
Emoji JS polyfill (print_emoji_detection_script) |
Modern OSes render emoji natively; the polyfill is for IE/legacy. |
Emoji styles (print_emoji_styles) |
Same. |
s.w.org dns-prefetch |
Only used by the emoji polyfill. |
<meta name="generator"> |
Mild info leak, no functional value. |
oEmbed auto-embed + wp-embed script |
Pasted URLs render as plain links. Use raw <iframe> in a Custom HTML block for occasional embeds. |
classic-theme-styles |
Default <button> rules; our SCSS supplies its own. |
wp-block-library + wp-block-library-theme (conditional) |
Loaded only on singular pages where has_blocks() is true. Archives, search results, and PHP-only templates skip the ~30KB. |
The theme ships no SEO meta tags by default — <title> is generated by add_theme_support('title-tag') and that's it. Most projects install a plugin for OG/Twitter cards, sitemaps, schema, and per-post controls:
- Yoast SEO — most ubiquitous, best UI for non-developer editors.
- Rank Math — leaner, more configurable, free tier ships more features than Yoast's.
- The SEO Framework — minimal, no upsells, no analytics tracking.
Pick one. Adding your own meta-tag function in functions.php is fine for a marketing site with predictable pages, but a plugin will pay off the moment editors need per-post overrides.
The theme is i18n-ready. load_theme_textdomain() is wired in theme_setup() and the text-domain is mgn (matches the theme slug). All translatable strings in templates and partials use __(), _e(), or esc_*_e() against this domain.
To add a translation, drop a .po / .mo pair into languages/ named mgn-{locale} (e.g., mgn-fr_FR.mo). WordPress loads it automatically based on the site language.
If you fork this theme to a new project and rename the directory, do a project-wide replace of the 'mgn' text-domain string to match the new slug.
The starter ships the bones of an accessible theme. Treat the second list as a checklist when extending it — the original theme this codebase descends from had most of these stripped, and they are easy to forget.
What's already wired:
- Semantic landmarks:
<header>,<main id="main-content">,<footer>. Every<nav>carries a uniquearia-label(Primary, Mobile menu, Footer). wp_body_open()runs immediately after<body>so plugins can inject correctly.<title>is generated viaadd_theme_support('title-tag').- Skip link —
Skip to main contentis the first focusable element, visually hidden until focused (offsets itself below the WP admin bar when present). - Hamburger toggle declares
type="button",aria-label,aria-controls="mobile-menu", and an initialaria-expanded="false". The drawer carries the matchingid="mobile-menu". JS keepsaria-expandedand the drawer'saria-hiddenin sync. - Drawer focus management — opening the drawer moves focus to the first focusable element inside it, traps Tab navigation while open, and restores focus to the toggle that opened it on close. Escape and overlay-click both close.
focus({ preventScroll: true })is used so closing doesn't jump the page when the sticky toggle is the source. - Focus styles —
:focus-visibleoutlines on hamburger, drawer close, back-to-top, and skip link. Nooutline: noneis left without a replacement. - The back-to-top button has
aria-label; the inline SVG icon inside it isaria-hidden="true"so screen readers announce the button once, not twice. Thetheme_icon()helper applies this default automatically.
What you should still add before going to production:
- Heading hierarchy. One
<h1>per page (post title or page title). Don't skip levels going deeper. - Editor discipline. WordPress media uploads should always have alt text. The theme leaves
the_post_thumbnail()and content images alone, so the burden is on whoever uploads. - Form labels. Any custom forms outside
searchform.phpshould have explicit<label>elements (oraria-label) tied to inputs. - Live regions for any dynamic content updates you add (form errors, AJAX status, etc.) —
aria-live="polite"for non-urgent,aria-live="assertive"for urgent.
Both CSS and JS animations honor prefers-reduced-motion: reduce:
- CSS — sticky-header transitions are explicitly disabled in
src/styles/_header.scss. - JS —
scrollToTop()falls back tobehavior: 'auto', and the preloader hides instantly instead of fading. Both checkmatchMedia('(prefers-reduced-motion: reduce)').matchesat runtime.
When adding new motion (GSAP timelines, scroll-triggered effects, parallax), mirror the pattern:
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduceMotion) {
// short-circuit to non-animated path
return;
}Evaluate matchMedia at the top of the animation function (not module-level) so users who change the OS preference mid-session are honored without a reload.
- Sensitive values are stored as PHP constants and are not committed to the repository.
- An
AGENTS.mdfile is included for AI-assisted development workflows (Codex, Claude Code, and similar tools).
For changes to layout, design, or behavior, work from src/ and rebuild. For content edits, use the WordPress admin editor and menus.