Theme Assets
Where a theme's stylesheets, scripts, images and fonts live, and why only two of the four get a version query.
Overview
Everything the browser fetches from a theme sits under its assets/ directory, addressed by one function. Nothing is registered, bundled or compiled: the file is where you put it.
Stylesheets and scripts come back with a cache busting query built from the file's modification time. Fonts and images come back clean, deliberately.
Structure
Inside assets/
The layout is a convention: the function takes any path under assets/. Counts are from the shipped WStyle theme.
assets/
├── css/ 50 stylesheets: default.css, theme.css, then one per surface
│ └── libs/ 5 third party bundles: bootstrap-icons, fontawesome, fonts, prism, wcp-table
├── js/ 55 scripts: default.js, money.js, then one per surface
│ └── libs/ 6 third party bundles: tom-select, intl-tel-input, jspdf, ...
├── images/ 27 entries, grouped: hero/, logo/, banks/, avatars/, addons/
├── videos/ anything heavier than an image
├── favicon.svg addressed like any other asset
└── component-showcase.html a live catalogue of the theme's own primitives
default.css and default.js load on every page, so every visitor pays for them. A stylesheet named after a surface is linked from that view and by nothing else.
component-showcase.html is the theme's own component catalogue. Open it in a browser before writing new markup.
Step by Step
1. Put the File in Place
- Drop the file under
assets/, incss/,js/orimages/. - Name a surface file after its surface:
css/balance.css,js/balance.js. - Third party bundles go unmodified in
css/libs/orjs/libs/, so upgrading one is a directory swap.
Nothing watches the directory: the file is reachable but unreferenced.
2. Link It from a View
- Site wide files belong in the layout's head, once.
- A surface's own stylesheet goes in that view's
{block name=head}. - A surface's own script goes in
{block name=scripts}, never inhead. Core scripts print before that block, so a head script runs too early and fails silently. - Write the path relative to
assets/. The function adds the rest.
Reload and read the markup. A link ending in ?v=1753974812 is your file, found on disk. A link with no query is the diagnostic below.
3. Reference It from PHP
- In
hooks.php, or any other PHP, call the same function on the active theme. - Inject markup through a layout hook point instead of editing the layout. An optional stylesheet ships without a second head.
The injected tag now appears wherever the hook point fires, with the same version query.
4. Add a Font
- Put the
woff2files and their@font-facestylesheet together undercss/libs/fonts/. - Inside that stylesheet, reference the font files relatively. Browsers resolve
url()against the stylesheet, not the page. - Link the stylesheet with the normal tag, and preload the font file the first paint needs.
- If the font already ships with a hashed query in its stylesheet, repeat that exact query on the preload.
The network panel shows one request per font file. Two requests mean the preload and the stylesheet disagree.
Reference
The Addressing Function
public function assetUrl(string $path = ''): string;
// $path relative to the theme's assets/ directory. A leading slash is trimmed,
// so 'css/default.css' and '/css/default.css' are the same request.
// An empty string returns the assets directory itself.
//
// returns {APP_URI}/templates/website/{Theme}/assets/{$path}
// plus ?v={mtime} when the extension is css or js AND the file exists on disk.
| Argument | Returned URL | Version query |
|---|---|---|
'css/default.css' | .../assets/css/default.css?v=1753974812 | Yes, the file's modification time |
'js/home.js' | .../assets/js/home.js?v=1753902114 | Yes |
'images/hero/banner.webp' | .../assets/images/hero/banner.webp | No, deliberately |
'css/libs/fonts/x.woff2' | .../assets/css/libs/fonts/x.woff2 | No, deliberately |
'css/typo.css' (no such file) | .../assets/css/typo.css | None. The URL is still returned, and 404s |
'' | .../assets/ | None |
A link with no version query says the file is not on disk. The function stats it only to read a modification time.
Calling It from a View
<!-- Smarty: one named parameter, 'path' -->
<link rel="stylesheet" href="{asset path='css/balance.css'}">
<script src="{asset path='js/balance.js'}" defer></script>
<!-- Twig: one positional argument -->
<link rel="stylesheet" href="{{ asset('css/balance.css') }}">
<!-- Plain PHP theme, and any PHP outside a view -->
<link rel="stylesheet" href="<?= Theme::active()->assetUrl('css/balance.css') ?>">
hooks.php and from any markup built outside a template.
assets/, and carries no version query.
Example
Site Wide Assets, Once, in the Layout
The head of a shipped layout, cut to the asset lines. Order matters: the icon font is preloaded before the stylesheet that declares it.
<link rel="icon" type="image/svg+xml" href="{asset path='favicon.svg'}">
<!-- The query is the hash already present in bootstrap-icons.min.css's own src.
Get it wrong and the browser fetches the font twice. -->
<link rel="preload" href="{asset path='css/libs/bootstrap-icons/fonts/bootstrap-icons.woff2'}?e34853135f9e39acf64315236852cd5a"
as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="{asset path='css/libs/bootstrap-icons/bootstrap-icons.min.css'}">
<link rel="stylesheet" href="{asset path='css/libs/fonts/urbanist.css'}">
<link rel="stylesheet" href="{asset path='css/theme.css'}">
<link rel="stylesheet" href="{asset path='css/default.css'}">
<link rel="stylesheet" href="{asset path='css/default-dark.css'}">
<!-- Loaded only where it is needed, decided by a variable, not by a second layout. -->
{if $is_client_area}<link rel="stylesheet" href="{asset path='css/client-nav.css'}">{/if}
<!-- Core scripts, before the page's own block. -->
<script src="{asset path='js/bootstrap.bundle.min.js'}" defer></script>
<script src="{asset path='js/money.js'}" defer></script>
<script src="{asset path='js/default.js'}" defer></script>
Surface Assets, in the View That Needs Them
A real view's two asset blocks. Stylesheets go in head, scripts in scripts. The library the page script needs is loaded in the same block, above it.
{extends file='layouts/default.tpl'}
{block name=head}
<link rel="stylesheet" href="{asset path='css/account-settings.css'}">
<link rel="stylesheet" href="{asset path='css/libs/wcp-table/table.css'}">
<link rel="stylesheet" href="{asset path='css/balance.css'}">
{/block}
{block name=scripts}
{* The library first, then the page script that uses it: same block, document order. *}
<script src="{asset path='js/libs/wcp-table/table.js'}" defer></script>
<script src="{asset path='js/balance.js'}" defer></script>
{/block}
{block name=content}
{* ... *}
{/block}
// A hook point takes a string and prints it as is, so build the tag here and
// leave the layout alone. Same function, same version query.
Hook::add("ui:client.head.css", 1, function (): string {
$href = Theme::active()->assetUrl('css/extra.css');
return '<link rel="stylesheet" href="' . $href . '">';
});
Pitfalls
A URL is versioned only when the function finds the file on disk. A link that comes back as css/balance.css with nothing after it is a wrong path, and it is about to 404.
A font is requested twice: once by your preload tag, once by the url() inside the font stylesheet. The function never touches that second one. Version the preload and the two URLs stop matching. The preload is wasted, and a face declared font-display: optional misses first paint and keeps the fallback metrics.
Bundles like the icon font ship their own hashed query inside src:. Repeat it exactly on the preload, or the browser downloads the font twice. Read the hash from the bundle's own stylesheet, not from another theme.
Deferred scripts execute in document order. A page script moved into {block name=head} runs before the theme's core script. It cannot see the global object, and fails without an error. One exception: a library the core script itself consumes loads in the head, above it.
The addressing function is for markup. A background image, font file or SVG mask referenced from inside a stylesheet resolves against that stylesheet, never through the function. Keep those references relative and keep the files beside the stylesheet that names them.
Related Articles
Merci pour votre retour !
Notre équipe d'assistance est disponible 24h/24 pour tout ce que vous ne trouvez pas ci-dessus.