# Theme Assets

https://dev.wisecp.com/es/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.

```bash
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

1. Drop the file under `assets/`, in `css/`, `js/` or `images/`.
2. Name a surface file after its surface: `css/balance.css`, `js/balance.js`.
3. Third party bundles go unmodified in `css/libs/` or `js/libs/`, so upgrading one is a directory swap.

Nothing watches the directory: the file is reachable but unreferenced.

### 2. Link It from a View

1. Site wide files belong in the layout's head, once.
2. A surface's own stylesheet goes in that view's `{block name=head}`.
3. A surface's own script goes in `{block name=scripts}`, never in `head`. Core scripts print before that block, so a head script runs too early and fails silently.
4. 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

1. In `hooks.php`, or any other PHP, call the same function on the active theme.
2. 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

1. Put the `woff2` files and their `@font-face` stylesheet together under `css/libs/fonts/`.
2. Inside that stylesheet, reference the font files **relatively**. Browsers resolve `url()` against the stylesheet, not the page.
3. Link the stylesheet with the normal tag, and preload the font file the first paint needs.
4. 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

```php
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

```html
<!-- 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') ?>">
```

- **{asset path='...'}**: Smarty. One named parameter, and only one. Mistype it and the tag falls back to an empty path: the link points at the bare assets directory instead of failing.
- **asset('...')**: Twig. Positional, and present in the sandbox's function allowlist. Same resolution, same string.
- **Theme::active()->assetUrl()**: PHP. What both tags call underneath. Use it from `hooks.php` and from any markup built outside a template.
- **$tadress**: Every page also receives the theme directory's URL as a plain variable. It is the theme root, not `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.

```html
<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.

```smarty
{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}
```

```php
// 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

> **No version query means the file is not there**
> 
> 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.

> **Fonts and images are unversioned on purpose. Do not "fix" it**
> 
> 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.

> **A preload's query must match the stylesheet's query character for character**
> 
> 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.

> **A page script in the head is a script that runs too early**
> 
> 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.

> **A url() inside CSS is resolved against the CSS file**
> 
> 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

- [The Theme Engine](https://dev.wisecp.com/en/the-theme-engine)
- [Theme Anatomy](https://dev.wisecp.com/en/theme-anatomy)
- [Your First Theme](https://dev.wisecp.com/en/your-first-theme)
- [Theme Performance and Caching](https://dev.wisecp.com/en/theme-performance-and-caching)
- [Theme Hooks and Output Filters](https://dev.wisecp.com/en/theme-hooks-and-output-filters)
