Assets
Føhn provides two helpers for enqueuing scripts and styles, one per build tool.
| Build tool | Helper | Reads |
|---|---|---|
Vite, through @studiometa/foehn-vite-plugin | ViteManifest | dist/.vite/manifest.json |
@studiometa/webpack-config | WebpackManifest | dist/assets-manifest.json |
They are not interchangeable: the two tools emit different formats. Reach for ViteManifest on a new project — it is what the starter and the demo use.
Vite
<?php
namespace App\Hooks;
use Studiometa\Foehn\Assets\ViteManifest;
use Studiometa\Foehn\Attributes\AsAction;
final class AssetHooks
{
#[AsAction('wp_enqueue_scripts')]
public function enqueue(): void
{
ViteManifest::fromTheme()
->enqueue('theme/assets/css/app.css', handle: 'theme-styles')
->enqueue('theme/assets/js/app.js', handle: 'theme-app', inFooter: true);
}
}Entry names are the paths given to the plugin's input in vite.config.js, because those are the keys Vite writes into the manifest. They are relative to the Vite project root, which is usually the package rather than the theme — so theme/assets/js/app.js, not assets/js/app.js.
What it handles for you
The dev server. While npm run dev runs, the plugin writes a hot file holding the server's URL. ViteManifest then loads the Vite client and the entries from that server instead of from the build, so hot module replacement works and a stale dist/ cannot shadow your edits. Nothing in the theme has to branch on it.
The CSS a script imported. A Vite JavaScript chunk carries the stylesheets it imported in a css array, separate from its own file. Enqueue the script and miss that array and the page loads with no styles and no error anywhere — so enqueue() always registers both.
The module type. Vite emits ES modules, and a classic <script> tag will not parse one. wp_script_add_data($handle, 'type', 'module') looks like the way to say so and is not: WP_Scripts reads strategy, before, after and data, never type. ViteManifest rewrites the tag through script_loader_tag, and only for the handles it enqueued.
A missing build. No manifest and no hot file means nothing is enqueued, rather than a fatal error. exists() reports which case you are in, and isDevServer() whether the dev server is running.
Where the build has to go
dist/ must be inside the theme. Only the theme directory is served, so a build written beside it is never reachable from a browser:
foehn({
input: ["theme/assets/js/app.js", "theme/assets/css/app.css"],
outDir: "theme/dist",
});fromTheme() looks in dist/ under the active theme; pass a different relative path if yours differs. fromChildTheme() does the same against the child theme.
Autoloading components
@studiometa/js-toolkit can discover components from the markup instead of being told about each one, which is what both themes do:
import { defineManifest, fromMetaGlob, registerManifests } from "@studiometa/js-toolkit";
import "@studiometa/ui/autoload";
const manifest = defineManifest({
packageName: "my-theme",
modules: fromMetaGlob(import.meta.glob("./components/*.js")),
});
registerManifests(manifest);import.meta.glob hands Vite a lazy importer per file, fromMetaGlob normalises that into the shape the loader wants, and registerManifests schedules the start. The loader mounts whatever [data-component] it finds and fetches only those modules, so a component nobody uses on a page costs nothing but a manifest entry.
Adding a component is dropping a file into components/ — nothing in app.js changes.
@studiometa/ui/autoload registers that package's own manifest as a side effect of the import, which is why it takes no arguments. data-component="Modal" then works with no import of Modal anywhere.
Twig components
studiometa/ui also ships its components as Twig templates. Install it and opt the hook in:
composer require studiometa/ui// app/foehn.config.php
use Studiometa\Foehn\Config\FoehnConfig;
use Studiometa\Foehn\Hooks\StudiometaUi;
return new FoehnConfig(hooks: [StudiometaUi::class]);That registers the @ui and @svg Twig namespaces on Timber's loader, which is the only way the components can be reached:
{% embed '@ui/Accordion/Accordion.twig' with { items: faq } %}
{% block title %}{{ item.title }}{% endblock %}
{% block content %}{{ item.content }}{% endblock %}
{% endembed %}The markup arrives carrying data-component="Accordion", so the Twig half and the JavaScript half meet without either importing the other.
It is a hook class rather than automatic registration because framework hook classes are opt-in by design — one registering itself because it happens to sit in a scanned package would let a composer update change what a site does.
Nothing breaks when the package is absent: the hook checks for it and returns the environment untouched. studiometa/ui is a suggest, so the framework itself gains no dependency.
Webpack
Installation
The WebpackManifest helper requires the PHP companion package:
composer require studiometa/webpack-configBasic Usage
Use the WebpackManifest class with #[AsAction] to enqueue your theme assets:
<?php
namespace App\Hooks;
use Studiometa\Foehn\Assets\WebpackManifest;
use Studiometa\Foehn\Attributes\AsAction;
final class AssetHooks
{
#[AsAction('wp_enqueue_scripts')]
public function enqueueAssets(): void
{
WebpackManifest::fromTheme()
->enqueueEntry('css/app', prefix: 'theme')
->enqueueEntry('js/app', prefix: 'theme', inFooter: true);
}
}This will:
- Load the manifest from
{theme}/dist/assets-manifest.json - Enqueue all CSS files from the
css/appentry - Enqueue all JS files from the
js/appentry (in the footer) - Add content-based version hashes for cache busting
Factory Methods
fromTheme()
Creates a manifest instance from the parent theme directory:
// Default: /dist/assets-manifest.json
WebpackManifest::fromTheme();
// Custom path
WebpackManifest::fromTheme('/build/manifest.json', 'build/');fromChildTheme()
Creates a manifest instance from the child theme directory:
WebpackManifest::fromChildTheme();Constructor
For full control, use the constructor directly:
$manifest = new WebpackManifest(
manifestPath: get_template_directory() . '/dist/assets-manifest.json',
distPath: 'dist/',
baseUri: get_template_directory_uri() . '/', // Optional
basePath: get_template_directory() . '/', // Optional
);Enqueueing Assets
enqueueEntry()
Enqueue all assets from a single entry:
$manifest->enqueueEntry(
entry: 'css/app', // Entry name in manifest
prefix: 'theme', // Handle prefix (e.g., 'theme-app-css')
inFooter: false, // Load scripts in footer
deps: ['jquery'], // Dependencies
media: 'all', // Media attribute for styles
);enqueueEntries()
Enqueue multiple entries at once:
$manifest->enqueueEntries(
entries: ['css/app', 'js/app'],
prefix: 'theme',
inFooter: true,
);Fluent Interface
All methods return $this for chaining:
WebpackManifest::fromTheme()
->enqueueEntry('css/app', prefix: 'theme')
->enqueueEntry('js/app', prefix: 'theme', inFooter: true)
->enqueueEntry('css/admin/editor-style', prefix: 'theme-editor');Conditional Loading
Since you control when to call enqueueEntry(), conditional loading is straightforward:
#[AsAction('wp_enqueue_scripts')]
public function enqueueAssets(): void
{
$manifest = WebpackManifest::fromTheme();
// Always load base styles
$manifest->enqueueEntry('css/app', prefix: 'theme');
// Load JS only on frontend
if (!is_admin()) {
$manifest->enqueueEntry('js/app', prefix: 'theme', inFooter: true);
}
// Load specific styles for single posts
if (is_singular('post')) {
$manifest->enqueueEntry('css/single', prefix: 'theme');
}
}
#[AsAction('admin_enqueue_scripts')]
public function enqueueAdminAssets(): void
{
WebpackManifest::fromTheme()
->enqueueEntry('css/admin/admin', prefix: 'theme-admin');
}
#[AsAction('login_enqueue_scripts')]
public function enqueueLoginAssets(): void
{
WebpackManifest::fromTheme()
->enqueueEntry('css/admin/login-style', prefix: 'theme-login');
}Graceful Degradation
The helper fails gracefully when the manifest file is not found (e.g., during development before the first build):
$manifest = WebpackManifest::fromTheme();
// Check if manifest was loaded
if (!$manifest->exists()) {
// Fallback to unversioned assets
wp_enqueue_style('theme-style', get_stylesheet_uri());
return;
}
$manifest->enqueueEntry('css/app', prefix: 'theme');Advanced: Accessing the Underlying Manifest
For advanced use cases, access the underlying Studiometa\WebpackConfig\Manifest instance:
$manifest = WebpackManifest::fromTheme();
$webpackManifest = $manifest->getManifest();
if ($webpackManifest !== null) {
// Access entry data directly
$entry = $webpackManifest->entry('js/app');
// Get all scripts with their attributes
foreach ($entry->scripts as $handle => $script) {
echo $script->getAttribute('src');
}
}See Also
- Hooks Guide - Learn about
#[AsAction] - @studiometa/webpack-config - The build tool