Front-end assets build (CSS, JS, images)
What npm run build produces, how the storefront picks minified and per-preset files, WebP images, precompressed files and how to add your own images.
Sellora ships with every front-end file already built, so a store does not need Node.js. Read this page when you change Blade views, stylesheets, scripts or template images and want to rebuild them.
One command#
npm install # once: Tailwind CSS, rtlcss, esbuild, sharp
npm run build # every step below, about 30 seconds (images are incremental)
npm run build:fast # the same without the image step
| Step | npm script | What it writes |
|---|---|---|
| css | build:css |
public/assets/css/main.css: the full Tailwind build (admin, installer, error pages and fallback). |
| rtl | build:rtl |
*.rtl.css mirrors of the storefront stylesheets (scripts/build-rtl.mjs). |
| plan | build:plan |
public/assets/build/plan.json from config/themes.php (php artisan sellora:assets-plan). Without PHP on the PATH the existing file is used; set PHP_BINARY to point at another PHP. |
| presets | build:presets |
Per-preset storefront stylesheets and a minified copy of every other stylesheet in public/assets/build/css (scripts/build-css.mjs). |
| js | build:js |
Minified copies of the JavaScript in public/assets/build/js (scripts/build-js.mjs, esbuild, no bundling). |
| images | build:images |
WebP copies and narrower widths of the template images (scripts/build-images.mjs, sharp). |
| manifest | build:manifest |
public/assets/build/manifest.json: file versions used by the PHP side. |
| compress | build:compress |
.br and .gz siblings of the built files (scripts/precompress.mjs). |
Run one step with node scripts/build.mjs --only=presets,manifest (the manifest is always refreshed after a partial run) or skip one with --skip=images. npm run dev still watches main.css only.
Edited a file without rebuilding?
The site never serves a stale copy. The manifest records the size, date and checksum of every source file. When a source differs from the build (you edited front.js, or ran only npm run build:css), Sellora serves the source file itself with a date-based version until you run npm run build again. Uploading files by FTP changes their dates but not their content, so they keep using the build.
Storefront stylesheets per preset#
Before this build, every storefront page loaded seven to nine stylesheets, including the full 690 KB main.css with the utilities of every preset and the admin. A storefront page now loads two files:
build/css/tw-{group}.css: Swiper's CSS plus a Tailwind build that scans only the views the preset can render.tw-kit.cssserves every market and standalone preset. It leaves out the classic presets' own views and section layouts.tw-classic-{preset}.cssserves one classic preset. It keeps that preset's section layouts and leaves out the other classic presets' layouts.tw-storefront.csscovers every storefront view. The page switches to it automatically when the home page uses a section layout that belongs to another preset. For example, if you add the "Tech 3" hero to a market preset, the page still gets its styles.
build/css/pack-{preset}.css:market.css,market-pages.css, the preset's own stylesheets,sellora-front.cssandsellora-checkout.cssin the original order, minified, withurl()s rebased and PNG/JPEG backgrounds pointed at their WebP copies.
Each file has an .rtl.css twin for right-to-left languages. The Google families that the template utilities use (Inter, Plus Jakarta Sans, Space Grotesk and Barlow) are linked directly from the page instead of through @import chains inside the CSS. The layout only uses this pair when it is current. In every other case it links the individual (minified) files, as before.
The Tailwind design system (theme tokens, plugins, custom utilities) lives in public/assets/css/tailwind.shared.css. Both tailwind.css (which builds main.css) and the generated storefront entries import it, so a token you change there reaches every build.
JavaScript#
- Every script is served minified from
build/js. Top-level names, licence comments and syntax are unchanged. - Swiper (148 KB) is only loaded on pages that contain a carousel, and always on classic presets (their
main.jsexpects it). video.min.js(545 KB) is only loaded when the page contains a Video.js player.- On classic presets the checkout no longer loads a second copy of jQuery.
- Scripts stay at the end of
<body>, after the content, so they never block rendering. Inline scripts pushed by pages rely on them running in order, which is why they are not deferred.
Versioned URLs: asset_v()#
<link rel="stylesheet" href='{{ asset_v("css/my-page.css") }}'>
<script src='{{ asset_v("js/sellora/my-page.js") }}' defer></script>
asset_v($path, $rtl = false) (and theme()->assetUrl(), which uses it) returns the minified copy with ?v=<content hash>. If the file is not in the build, it returns the source with ?v=<modified time>. With $rtl = true it returns the .rtl.css sibling when one exists. Because the URL changes with every content change, browsers and CDNs can cache these files for a year.
Set SELLORA_ASSET_BUILD=false in .env to always serve the source files (handy while debugging CSS).
Images#
Template images (public/assets/images)#
npm run build:images writes the following files next to each PNG or JPEG:
{file}.webp: same pixel size, quality 78 to 82, kept only when at least 10% smaller.{file}.w480.webp,{file}.w960.webpand{file}.w1600.webpfor wide images.
It also writes public/assets/build/images.json with the dimensions. On the demo images the WebP copies cut 103 MB down to 20 MB.
Uploaded images#
When an image is uploaded, MediaService writes the configured sizes (config/sellora.php image_sizes) and a .webp copy of the original and of each size. Set SELLORA_AVIF=true to also write .avif copies when PHP's GD extension supports AVIF. For images uploaded before this version, or imported from another platform, run:
php artisan sellora:images-optimize # only the missing copies (default)
php artisan sellora:images-optimize --all # re-create every size and copy
php artisan sellora:images-optimize --path=uploads/2026 --dry-run
How pages use them#
- Automatically. A middleware (
App\Http\Middleware\OptimizeImages) rewrites every storefront<img>,<picture><source>and inlinestyle="background-image: url(...)"that points to a PNG or JPEG with a WebP copy, and addsdecoding="async". The copy has the same pixel size, so no layout changes. The storefront's CSS already requires browsers that all decode WebP (Safari 16.4+, Chrome 111+). Admin and installer pages are not touched. SetSELLORA_WEBP=falseto turn this off. - In your own views, use the component or the helper:
<x-img :src='$post->image_url' alt="" class="is-photo" sizes="(max-width: 639px) 100vw, 420px" />
{{ img_tag('assets/images/banners/my-banner.png', ['class' => 'w-full', 'sizes' => '100vw', 'eager' => true]) }}
<img src='{{ img_url($path, "medium") }}' alt="">
| Option | Effect |
|---|---|
size |
Size of an uploaded image (thumb, small, medium, large). |
sizes |
Adds a srcset with the narrower copies (template images) or the resized copies (uploads). Only use it where the CSS sets the image width, and never on an image whose src JavaScript swaps (galleries, swatches): with a srcset, a changed src is ignored. |
dims |
Adds the image's own width/height. Do not use it on images that the CSS sizes by height only. |
eager |
loading="eager" and fetchpriority="high" for the main (LCP) image. Everything else is loading="lazy" by default. |
picture |
Wraps the image in <picture> with an AVIF <source> when an .avif copy exists. |
Every other attribute (class, alt, width, data-*) is written as given, so the markup the CSS depends on stays the same.
The middleware also sends a Link: rel=preload header for the page's two stylesheets and for its LCP image (the <img fetchpriority="high">, when it is a plain <img> without srcset).
Adding images#
Put new template images under public/assets/images and run npm run build:images. Reference them by their normal .png/.jpg path; the WebP copy is picked up automatically.
Server configuration#
The C2 assets block in public/.htaccess does the following on Apache:
- Serves
file.css.br(or.gz) instead offile.cssto browsers that accept it, with the rightContent-Type,Content-EncodingandVary: Accept-Encoding. - Adds the WebP, AVIF and font MIME types.
- Gives versioned URLs a one-year
immutablecache.
The C1 headers block above it sets the cache lifetimes for the original files.
On nginx, add the following (inside the server block that serves public/):
# Sellora: built files are only linked with ?v=<content hash>, so they can be cached for a year
location ^~ /assets/build/ {
gzip_static on; # serves file.css.gz (ngx_http_gzip_static_module)
brotli_static on; # serves file.css.br (ngx_brotli; remove the line if the module is not installed)
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Vary Accept-Encoding;
try_files $uri =404;
}
# other static files: precompressed when a sibling exists, cached for a day (CSS/JS) or 30 days (media, fonts)
location ~* ^/assets/.+\.(css|js|svg|json|ttf|otf|eot)$ {
gzip_static on;
add_header Cache-Control "public, max-age=86400";
add_header Vary Accept-Encoding;
try_files $uri =404;
}
location ~* ^/(assets|storage|uploads)/.+\.(png|jpe?g|webp|avif|gif|woff2?)$ {
add_header Cache-Control "public, max-age=2592000";
try_files $uri =404;
}
Make sure the mime.types file nginx includes lists image/webp webp; and image/avif avif; (recent nginx versions do).
Service worker#
public/sw.js is registered as /sw.js?v=<build version>. A new build changes that URL, so browsers install the new worker, and it deletes the previous version's caches when it activates. Versioned files (?v=) and fonts are served cache-first. Other static files are served from the cache and refreshed in the background. Pages are network-first. Cart, checkout, account, admin, API and /state are never cached. See PWA.