Browser builds
An HTML entry lets the browser start at a document rather than a module:
{ "build": { "targets": { "web": { "entry": "index.html", "outdir": "dist" } } } }
The tags in it are the build's inputs:
<link rel="stylesheet" href="./styles.css" /> <script type="module" src="./src/entry.client.tsx"></script>
They become hashed URLs in dist/index.html:
<link rel="stylesheet" href="/assets/styles-621d3b66.css" /> <script type="module" src="/assets/entry.client-fccaa347.js"></script>
<script type="module"> | An entry — it and everything it imports become one browser bundle |
| Everything else relative | Copied: stylesheets, favicons, images, classic scripts |
| Both | Content-hashed into <outdir>/assets, so the whole directory caches immutably |
| Everything else in the file | Untouched byte for byte — title, meta, Open Graph and inline scripts |
A relative path is an input. A rooted path, a URL and a data: URI are left exactly as written — the escape hatch for anything the build should keep out of. Dynamic import() chunks land in the same directory, hashed by the bundler.
Imported assets
import logo from "./logo.png"; document.body.innerHTML = `<img src="${logo}">`;
The file is emitted with a content hash and the import becomes its URL, such as /assets/logo-1a2b3c4d.png. Images, fonts, media, PDFs and .wasm are assets; a .json import stays a module.
| Hashed | A changed file is a changed URL, so a cache cannot serve last week's file |
| Counted by the build | The file is in the output because a module imported it — nothing to remember in esdev.json |
Not in --lib | A library cannot know where the consuming build serves a file from, so it refuses |
Files named by URL
const icon = new URL("./icon.svg", import.meta.url);
A file named this way is copied into the output with a content hash, beside the chunk that names it, and the URL is rewritten to point at the copy. The URL stays relative to the module, so it works wherever the output is served from, and in a --lib build the file ships inside the package. Any file that is not a JavaScript or TypeScript module is copied this way; a module is built as a chunk instead.
The path must be a literal starting with ./ or ../, read exactly as written against the module's directory. A path to a file that does not exist, a path with a ?query or #fragment, and a computed path are left as written.
Use assets for a file nothing imports — a favicon.ico or robots.txt — where the name must not change. A relative asset path in a target resolves against the entry module's directory and is copied into the deployment.
Stylesheets and CSS Modules
A <link rel="stylesheet"> is an entry too; so is esdev build styles.css. It and everything it @imports become one bundled stylesheet, and a relative url() is followed, so fonts and images travel with the stylesheet. --minify drops comments, collapses whitespace, shortens safe colours and zero dimensions, and merges safe repeated declarations.
Build a stylesheet directly
Use a CSS entry when the stylesheet is the deployable artifact rather than part of an HTML target:
styles/ app.css theme.css
/* styles/app.css */ @import "./theme.css"; .button { color: #ffffff; margin: 0px 0px 0px 0px; }
esdev build styles/app.css --minify # → dist/app.css
dist/app.css contains the imported rules, with no local @import left to fetch. In this example its declaration becomes .button{color:#fff;margin:0}. Use --out=public/site.css when the deployment expects a specific filename. The build writes a relative url(./font.woff2) under an assets/ directory beside the CSS and rewrites the URL to that emitted file.
Choose the entry that owns the stylesheet
Use an HTML entry for a browser application: esdev hashes the stylesheet and rewrites the document's <link>. Use a direct CSS entry when another system links or publishes the output and needs the stable filename you selected. A rooted, absolute, or remote @import is outside the build and remains in the stylesheet exactly as written.
A *.module.css imported from JavaScript is scoped to the file that declares it, and the import resolves to the mapping:
import styles from "./Button.module.css"; // { button: "button_a1b2c3d4" }
Class names, id selectors and @keyframes are renamed; :global(…) opts out. Every module's CSS is collected into one hashed stylesheet and linked from the document, never injected from script.
composes reuses a class from the same file, another module, or global. The mapping's value becomes a transitive list of class names. A .css that is not .module.css is emitted unscoped for third-party stylesheets.
A stylesheet that imports tailwindcss is compiled with the project's Tailwind in any of these paths — see Tailwind CSS.
Sass, Less and other stylesheet languages
A plugin that compiles a stylesheet language returns { code, type: "css" }, and esdev bundles, scopes and links what it made, as if it were a .css file: import styles from "./Card.module.scss" gets scoped names, and <link rel="stylesheet" href="./theme.scss"> is written as a hashed .css. A plugin that rewrites CSS and keeps it CSS (PostCSS) works the same way. @import and url() resolve from the source file's directory.
import * as sass from "sass"; export default { name: "sass", transform: { filter: { id: /\.scss$/ }, handler: (code, id) => ({ code: sass.compileString(code, { url: new URL(`file://${id}`) }).css, type: "css" }), }, };
No syntax lowering or vendor prefixing, and no per-file typed class names.
Output layout
The browser outdir contains the document plus its hashed assets directory. Dynamic chunks, stylesheets and imported files all live beside the entry so the directory can be served as one deployable unit. For target configuration and safe replacement rules, see Project builds.