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.

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.

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")

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