Manual Assets

Assets

elements man assets Read as markdown

Images, fonts, PDFs, downloads. Any file your app ships that is not code.

There are two kinds, and they are not the same problem:

  • Static assets live in your source tree. You know their names when you write the code. They are content-hashed at build time and served from /assets/.
  • Dynamic assets come from the database: a product photo, an avatar, an upload. You do not know the filename until the row is read, so a build-time URL cannot exist. These are served by a route.

Static assets

Put them anywhere under app/. The scaffold uses app/shared/assets/.

app/shared/assets/
  favicons/favicon.svg
  products/quad-lineup.jpg

In markup, write a path

<img src="./hero.jpg" alt="">
<link rel="icon" href="../../shared/assets/favicons/favicon.svg">

Relative paths only. The build rewrites the attribute to the file's content-hashed URL:

<img src="/assets/app/pages/home/hero.a17f3c0b9d2e4a51.jpg" alt="">

This works on src, href, poster and data, and on the content of the meta tags that hold URLs (og:image, twitter:image, and the rest of that family). content is a generic attribute name, so the URL-bearing properties are a fixed list rather than anything inferred.

It also works in CSS:

.hero { background-image: url("../assets/products/quad-lineup.jpg"); }

In code, import it

An import of a non-code file gives you its URL as a string:

import favicon from "#app/shared/assets/favicons/favicon.svg";

favicon;  // "/assets/app/shared/assets/favicons/favicon.f9e9effe4ae761bd.svg"

An import is the only way to get an asset URL into a value. You need one the moment the asset is part of data rather than one fixed spot in markup:

import quad from "#app/shared/assets/products/quad.jpg";
import sidecar from "#app/shared/assets/products/sidecar.jpg";

const CASES = [
  { name: "Quad",    photo: quad },
  { name: "Sidecar", photo: sidecar },
];
<li e:for={c of CASES}>
  <img src={c.photo} alt={c.name}>
  <span>{c.name}</span>
</li>

The URL is inlined at build time, so there is no runtime lookup and no helper to call. An imported asset and a written path produce the identical URL. They are one mechanism with two entry points.

Importing an asset makes it servable. That holds from server-only code too, so a route that builds a webmanifest or an rss feed can import an icon and get a real URL, and it holds even when nothing routes to the importing file: the import is the reference, not the reachability of the code around it.

Which to use: a path when the file sits next to the template that uses it, an import for anything shared or anything that has to travel as a value. The scaffold imports its favicon because app/shared/assets/ is nowhere near app/pages/home/.

# is for imports, not for URLs

#app/... is import-map syntax. It belongs in an import statement:

import favicon from "#app/shared/assets/favicons/favicon.svg";

In an attribute or a CSS url(), # means what it means in those languages. A page anchor, an SVG fragment. It is left alone:

<a href="#pricing">Pricing</a>                 <!-- an anchor, untouched -->
<img src="#app/shared/assets/logo.svg">        <!-- NOT an asset path -->
.icon { fill: url(#gradient); }                /* an SVG paint server */

Bare imports

A clause-less import of an asset binds nothing, so there is nothing to use and the statement is dropped:

import "#app/shared/assets/logo.svg";   // no effect

Only .css does something useful bare. It links the stylesheet:

import "./style.css";

Dynamic assets

When the filename lives in a database row, no build-time URL exists. Store the bytes and serve them from a route.

Store the file, with its content type and a hash you can cache on:

create table photos (
        id uuid primary key default uuidGenerateV7(),
     bytes bytea not null,
  mimeType text  not null,
      hash text  not null
);

Serve them from a route. A route that returns bytes is not a page, so it lives in app/routes/:

// app/routes/photos.ts
import { Request, Response, sql } from "@elements/app";

interface Photo {
  bytes:    Buffer;
  mimeType: string;
}

export default function servePhoto(req: Request, res: Response) {
  let id = req.params.id;

  let photo = sql<Photo>(`
    select bytes, mimeType from photos where id = ${id}::uuid
  `).first();

  if (!photo) {
    res.status(404);
    return "";
  }

  res.setHeader("Content-Type", photo.mimeType);
  res.setHeader("Cache-Control", "public, max-age=31536000, immutable");

  return photo.bytes;
}
// index.ts
app.route("/photos/:id", servePhoto);

Then the URL is ordinary data:

<img src={`/photos/${row.photoId}?v=${row.hash}`} alt={row.name}>

The ?v=<hash> is what makes immutable safe: the URL changes when the bytes change, so a browser can hold the old one forever without ever showing stale content. That is the same trick the static pipeline plays with a hashed filename.

For uploads, accepting the file in the first place, see elements man recipes/file-upload.