TAW FrameworkAssets and Vite integration

Assets and Vite integration

How TAW Core uses Vite for script and style loading, how critical CSS is inlined, and how to reference hashed assets anywhere in your templates.

How the asset pipeline works

TAW Theme uses Vite as its build tool. During development, Vite serves assets via a local dev server with instant HMR. For production, it compiles and content-hashes every file into public/build/.

TAW Core bridges Vite and WordPress through TAW\Support\ViteLoader, a PSR-4 autoloaded class shipped inside taw/core. You do not need to wire anything up manually — Theme::boot() handles it.

How dev-mode is detected

ViteLoader::isDevServerRunning() connects to the dev server (localhost:5173 by default) and confirms it's actually Vite by requesting GET /@vite/client and checking for an HTTP 200 response — not just that something is listening on the port.

A bare port-open check is a false-positive trap: any unrelated process — another dev server, a Docker container, anything — can end up bound to port 5173 for reasons that have nothing to do with your project. When that happens, the theme serves dead localhost:5173 asset URLs in production, with no assets loading at all, even though npm run build succeeded and the manifest on disk is completely correct.

Optional hot-file convention (recommended, Laravel Vite plugin-style): have vite.config.js write the dev server's actual URL to public/build/hot on startup and delete it on shutdown. ViteLoader reads this to resolve the correct host:port before probing — useful if the dev server ever needs to bind a non-default port. The default TAW Theme scaffold ships this plugin already; see vite.config.js's hotFilePlugin.

The scaffold's vite.config.js sets server.strictPort: true deliberately — it does not auto-move to a free port on conflict. server.origin (needed for correct absolute font URLs) is hardcoded to port 5173; if Vite silently moved to a different port, that value would go stale with no error, and only fonts would quietly 404. If npm run dev fails to start, something else has port 5173 — find it with lsof -i :5173 and stop it, rather than letting two dev servers collide.

Entry points

FileRole
resources/js/app.jsMain JS entry — Alpine, Swup, all block DOM init, imports app.css and app.scss
resources/css/app.cssTailwind v4 directives + any globally-needed third-party CSS (e.g. PhotoSwipe)
resources/scss/app.scssGlobal custom SCSS — @use 'fonts' lives here
resources/scss/critical.scssAbove-the-fold CSS — inlined in <head> as a <style> tag
resources/scss/_fonts.scss@font-face declarations — never add these to critical.scss
resources/fonts/Self-hosted WOFF2 font files

Never add @font-face rules to critical.scss. Font declarations belong in _fonts.scss, which is imported by app.scss. Including them in the critical path inlines large base64 data into every page's <head>.

Third-party CSS that is needed globally (e.g. PhotoSwipe) should be imported in resources/css/app.css, not inside JavaScript files. This keeps it as a genuine stylesheet in both dev and production — no HMR injection conflicts, no edge cases when multiple entry points import the same file.

How production assets are loaded

TAW Core's asset strategy is designed to avoid render-blocking resources:

  • critical.scss is compiled and inlined as a <style> tag directly in <head>. This eliminates a network round-trip for above-the-fold styles.
  • app.css / app.scss are loaded asynchronously via media="print" + onload swap — non-render-blocking.
  • JS is loaded as an ES module (type="module").
  • All filenames are content-hashed for cache-busting — no version query strings needed.

Optimizer-exclusion hardening

Every module <script> tag (plus its modulepreload/preload <link>s) and every Vite-extracted stylesheet <link> carries data-no-optimize="1" data-cfasync="false" data-no-defer="1" data-no-minify="1" — standard exclusion signals that WP Rocket, Autoptimize, Perfmatters, and LiteSpeed Cache all document and honor when they hook WordPress's own script_loader_tag/style_loader_tag filters. Vite's output is already minified, hashed, and (for JS) split into ES modules that require exact, un-mangled execution order — nothing a generic optimizer does to it is safe.

This hardening does not cover every failure mode. It only helps against optimizer plugins that hook WordPress's normal tag filters. A confirmed real incident: a host's JS optimization plugin (WPMUdev Hummingbird) rehosted a client site's compiled entry chunk onto its own CDN — one with no Access-Control-Allow-Origin header. Module scripts always require CORS to execute, so the browser hard-blocked the fetch (net::ERR_FAILED), and the entire bundle — Alpine.js and all core init logic included — silently never ran, with zero visible errors on the page. Hummingbird's CDN-rehosting feature rewrites the raw HTML output buffer directly, bypassing WordPress's filters entirely, so it never sees these attributes. Any host-level "combine/serve JS from CDN" feature must exclude the theme's own build output (public/build/assets/) via that plugin's own admin UI — no marker on the tag can force this.

Fast diagnostic for this failure mode — a page that looks broken with zero application-level errors:

  1. Check window.Alpine === undefined in the console after page load — true means the entry bundle never executed.
  2. Check getComputedStyle(document.documentElement).getPropertyValue('--header-height') — an empty string means the same thing.
  3. Check the Network tab for a cross-origin CORS block on the module script request before assuming it's an application bug.

ViteLoader API (TAW\Support\ViteLoader)

ViteLoader is the OOP Vite bridge shipped in taw/core. It is PSR-4 autoloaded — no explicit include needed.

use TAW\Support\ViteLoader;

// Resolve any theme asset URL — returns the dev-server URL in dev, hashed build URL in prod
$fontUrl = ViteLoader::assetUrl('resources/fonts/Inter-Regular.woff2');

// Check if the Vite dev server is running
if (ViteLoader::isDevServerRunning()) {
    // dev-only logic
}

// Enqueue an additional Vite entry point (e.g. a standalone block script)
ViteLoader::enqueueAsset('my-block', 'resources/js/my-block.js');

// Override the main entry point — call BEFORE Theme::boot()
ViteLoader::init('src/main.ts');
Theme::boot();

// Inline critical CSS directly into <head>
ViteLoader::inlineCriticalCss('resources/scss/critical.scss');

// Add modulepreload hints for JS chunks
ViteLoader::preloadAssets(['resources/js/chunks/vendor.js']);

The legacy procedural functions vite_asset_url() and vite_is_dev() are no longer in the Composer files autoload and will not be available globally. Use ViteLoader::assetUrl() and ViteLoader::isDevServerRunning() instead.

Per-block assets

Each block can ship its own style.scss (or style.css) and script.js. Both files are auto-detected and auto-enqueued — no registration needed. SCSS takes priority over CSS when both exist.

Blocks/Hero/
├── Hero.php
├── index.php
├── style.scss   ← per-block CSS (SCSS takes priority over CSS; enqueued as <link> in <head>)
└── script.js    ← per-block JS (loaded as type="module")

A block's script.js can import '...css' — e.g. a third-party lightbox library's stylesheet — and it's enqueued automatically alongside the script, in both dev and production. A production build extracts that CSS onto the script's own manifest entry (not the block's style.scss/style.css entry); BaseBlock::enqueueProdAssets() reads both, so no extra wiring is needed.

Assets are only enqueued on pages that actually use the block. Your homepage does not load your blog's scripts.

Queueing assets before wp_head

Call BlockRegistry::queue('id') before get_header() to schedule the block's assets in <head>. If you forget, BlockRegistry::render() enqueues assets as a fallback — they land after wp_head, but a <link> is printed inline.

<?php
// front-page.php
use TAW\Core\Block\BlockRegistry;

BlockRegistry::queue('hero', 'features', 'stats');
get_header();
?>

Block script responsibilities

When a view-transition library is used (TAW ships with Swup), each block script only runs once — on the first page load. A block script.js is therefore best used for:

  1. Alpine component registrationAlpine.data('componentName', factory) for any x-data="componentName" elements in the block's template.
  2. One-time global setup — anything that binds to the document/window and doesn't need to re-run per navigation.

DOM initialization that must run on every navigation (carousels, lightboxes, animations) should live in app.js. See Using JavaScript View Transition Libraries below.

Vite configuration

Vite is configured in vite.config.js at the theme root. The default config handles Tailwind v4 via the official Vite plugin, SCSS compilation, and the public/build/ output directory. Edit this file to add new entry points or adjust build targets.

Commands

CommandDescription
npm run devStart Vite dev server (port 5173) with HMR
npm run buildProduction build → public/build/ with hashed filenames

public/build/ is gitignored. Build artifacts are generated at deploy time — never commit them.


Using JavaScript View Transition Libraries

TAW ships with Swup v4 for SPA-style page transitions, but the patterns below apply equally to Barba.js, Taxi.js, the native View Transitions API, or any library that swaps page content without a full browser reload.

The core problem

A view-transition library intercepts link clicks, fetches the new page, and swaps a portion of the DOM (in TAW, that portion is <main id="content">). Everything outside that container — the <header>, <footer>, and all scripts already loaded — stays alive for the whole session.

Block JavaScript files execute once on the initial page load and never again. When the user navigates to a page with a block whose script wasn't loaded on the first page, the DOM for that block appears but its JavaScript initialization never runs.

Root cause: block scripts live outside the swapped container

WordPress enqueues block scripts in <body> (outside <main id="content">). A view-transition library replaces #content's HTML on each navigation but leaves everything else untouched. Any script registered for a block that wasn't on the landing page is never executed during the session — the block renders but stays inert.

The two naive workarounds both fail:

  • Re-running scripts on each swap — scripts in <body> (outside the swapped container) are still missed.
  • Delegating to a custom event — only scripts that have already loaded have listeners to fire.

The reliable fix: centralize initialization in app.js

The only script that reliably executes on every page is the main entry pointapp.js. Moving all DOM-initialization logic there eliminates the timing dependency entirely.

// app.js — import everything needed for all block types
import EmblaCarousel      from 'embla-carousel';
import AutoPlay           from 'embla-carousel-autoplay';
import PhotoSwipeLightbox from 'photoswipe/lightbox';

// Define one init function per block type
function initGalleries()    { /* Embla on .image-gallery__embla    */ }
function initTestimonials() { /* Embla on .testimonials__embla     */ }
function initPhotoSwipe()   { /* PhotoSwipe on [data-pswp-gallery] */ }

// Run everything on first load AND after every navigation
function initAll() {
    initGalleries();
    initTestimonials();
    initPhotoSwipe();
    // add your block init functions here
}

document.addEventListener('DOMContentLoaded', initAll);

// Hook into your library's "content replaced" lifecycle event
// (Swup: page:view | Barba: after | native VT API: after transition)
yourTransitionLibrary.on('page:view', initAll);

If you add a new block that needs per-navigation initialization, add its init function to initAll(). Never rely solely on a block script's event listener.

Make every init function idempotent

Because initAll() runs after every navigation, each function must be safe to call multiple times on the same page. Set a guard attribute on the element after initialization and check for its absence before running.

function initGalleries() {
    document.querySelectorAll('.image-gallery__embla:not([data-gallery-ready])').forEach(root => {
        const viewport = root.querySelector('.image-gallery__viewport');
        if (!viewport) return;

        const embla = EmblaCarousel(viewport, { loop: true });

        root.setAttribute('data-gallery-ready', '');
        window._tawCleanup.add(() => embla.destroy());
    });
}

Guard attributes are only present on live DOM nodes. When the transition library swaps #content, the new HTML arrives from the server without guard attributes, so initAll() correctly re-initializes all blocks on the incoming page.

Teardown before the swap

Libraries like Embla or Splide attach ResizeObservers and event listeners to DOM nodes. If those nodes are removed without calling .destroy(), the observers keep firing against detached elements — a memory leak that accumulates across navigations.

Register a teardown callback immediately after creating any such instance:

// window._tawCleanup is a Set<() => void> initialized in app.js
window._tawCleanup.add(() => embla.destroy());

Hook into your library's before-swap lifecycle event to run and clear all registered teardowns:

// Swup:
swup.hooks.before('content:replace', () => {
    window._tawCleanup.forEach(fn => fn());
    window._tawCleanup.clear();
});

// Barba.js:
barba.hooks.before(() => {
    window._tawCleanup.forEach(fn => fn());
    window._tawCleanup.clear();
});

Alpine.js lifecycle

Alpine binds reactive state to specific DOM nodes. When those nodes are replaced, the old bindings must be cleaned up (Alpine.destroyTree) and the new nodes must be initialized (Alpine.initTree). Alpine must only be started once — never call Alpine.start() again after the first page load.

// Before the swap — destroy Alpine on the outgoing content
yourLib.on('before-swap', () => {
    Alpine.destroyTree(document.getElementById('content'));
});

// After the swap — initialize Alpine on the incoming content
yourLib.on('after-swap', () => {
    Alpine.initTree(document.getElementById('content'));
});

Anything outside #content (e.g. a header menu component) is never touched by these calls and stays initialized throughout the session.

Alpine component registration from block scripts

Block scripts that register named Alpine components (Alpine.data('name', factory)) must handle two scenarios: the script running before Alpine starts (first page load) and the script being injected and executed after Alpine has already started and called initTree (subsequent navigations).

// Blocks/PostGrid/script.js

const registerVideoModal = () => {
    Alpine.data('videoModal', () => ({
        isOpen: false, embedUrl: '',
        openVideo(url) { this.embedUrl = url; this.isOpen = true; },
        close()        { this.isOpen = false; this.embedUrl = ''; },
    }));
};

if (window._alpineStarted) {
    // Script loaded after Alpine.initTree already ran — register and re-init any elements
    // that were processed without the component definition.
    registerVideoModal();
    document.querySelectorAll('[x-data="videoModal"]').forEach(el => {
        Alpine.destroyTree(el);
        Alpine.initTree(el);
    });
} else {
    // Normal first-load path: register before Alpine.start() is called.
    document.addEventListener('alpine:init', registerVideoModal);
}

window._alpineStarted is set to true after Alpine.start() returns in app.js. Block scripts check this flag to know which path to take.

The taw:page-view custom event

app.js dispatches a taw:page-view CustomEvent on document after initAll() completes on every navigation. Block scripts that need to react to page changes beyond what initAll() covers can listen to it:

document.addEventListener('taw:page-view', () => {
    updateActiveMenuLinks();
});

Since app.js's initAll() runs first and sets the guard attributes, any duplicate attempt by a block script's listener is a no-op — the guards prevent double-initialization.

Swup v4 in this theme

The transition animation is a simple opacity fade on #content:

#content { opacity: 1; transition: opacity 180ms ease; }
html.is-animating #content { opacity: 0; }

Swup adds html.is-animating when navigation starts and removes it after the enter animation ends. The same CSS rule drives both exit and enter.

PluginPurpose
@swup/head-plugin (persistAssets: true)Syncs <head> elements; keeps already-loaded scripts across navigations
@swup/scroll-pluginScrolls to top after each swap
@swup/preload-pluginPreloads target page on hover/focus