Skip to content

Front-end Asset Management

Front-end Asset Organization

By default, assets sources are stored under PROJECT_ROOT/resources and then "compiled" into src/public/ directory. These assets will be served by CDN to ensure optimal performance. The following is a summary of assets locations in our system:

CSS: resources/pcss/ => public/css/

JavaScript: resources/js/ => public/js/

Images: resources/img/ => public/img/

Fonts: resources/fonts/ => public/build/assets/fonts/ (self-hosted, processed by Vite)

Videos: CDN (Amazon S3 + CloudFront)

There are some npm tasks/commands to transform our asset sources into production-ready assets:

.js files should be transpiled, versioned and minified for production.

PostCSS files should be compiled into CSS, optimized/minified and versioned to avoid caching problems.

Cacheable Assets

Assets managing using PHP

We use Laravel mix-compatible API and versioning system.

We also combine this with the asset() helper to generate CDN URLs.

This line will automatically add the CDN URL (based on the .env variable ASSET_URL) and a cache-busting query string to prevent cache problems on client side:

html
<!--src-->
@vite('resource/js/app.js')

<!--output (example)-->
<script  type="module" src="https://assets.interaction-design.org/js/app-rAndOm.js" defer></script>

Note that random part of the filename depends on file content (checksum) and thus changed only when then content is changed.

Fonts

The project self-hosts Arimo font files instead of loading them from Google's CDN. This eliminates the external domain dependency, avoids extra DNS lookups, and removes the privacy concern of Google tracking font requests.

Font files are stored in resources/fonts/arimo/ and processed by Vite like any other asset; they get content-hashed, copied to public/build/assets/fonts/, and served via CloudFront in production.

How it works

  1. Source files: resources/fonts/arimo/*.woff2 — the raw font files
  2. CSS declarations: resources/css/web-fonts.css@font-face rules with src: url("../fonts/arimo/...") relative paths
  3. Vite processing: During build, Vite resolves the CSS url() references, copies the .woff2 files to public/build/assets/fonts/ with content hashes, and rewrites the URLs in the compiled CSS
  4. CDN delivery: In production, ASSET_URL points to CloudFront, so fonts are served globally with no extra setup

Updating fonts

To download the latest font files from Google Fonts:

bash
npm run fonts:update

This runs the fontsUpdate gulp task (scripts/fonts/update-google-fonts.mjs), which iterates the FONTS array, fetches each font's CSS from the Google Fonts API, and downloads only the whitelisted subsets.

Adding a new font

  1. Add an entry to the FONTS array in scripts/fonts/update-google-fonts.mjs:
js
{
    name: 'open-sans',
    url: 'https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;700&display=swap',
    subsets: ['latin', 'latin-ext'],
}
  1. Run npm run fonts:update: the script downloads the .woff2 files to resources/fonts/{name}/
  2. Add @font-face declarations to resources/css/web-fonts.css.

Font preloading

The normal-weight latin subset (arimo-normal-latin.woff2) is preloaded via <link rel="preload"> in the document <head>. This eliminates the CSS -> font waterfall: without preloading, the browser discovers the font only after downloading and parsing the CSS that contains the @font-face rule (HTML → CSS → font). With the preload hint the browser starts fetching the font in parallel with CSS, reducing FOUT and improving LCP when text is the largest contentful element.

Only this single variant is preloaded because:

  • Normal weight renders all body text (the most common variant by far)
  • The latin subset covers U+0000–00FF, handling virtually all English content
  • It is the smallest file (~20 KB), so the bandwidth cost is minimal
  • Preloading multiple fonts would compete for bandwidth and negate the benefit

The crossorigin attribute is required even for same-origin fonts — @font-face fetches always use anonymous CORS mode, and without crossorigin the preloaded response won't match, causing a double download.

References