Transpiling JavaScript

The scripts key bundles and transpiles JavaScript with esbuild. It handles .js, .ts, .jsx and .tsx out of the box โ€” TypeScript and JSX need no extra setup.

A single script

{
  "scripts": [
    {
      "in": "src/js/main.ts",
      "out": "dist/js/scripts.js",
      "options": {
        "sourcemap": true,
        "minify": true,
        "justMinified": false,
        "format": "iife",
        "target": "es2019"
      }
    }
  ]
}

Each entry has in, out and options:

  • in โ€” an entry file, an array of entry files, or a glob pattern.
  • out โ€” the output file, a directory when in has multiple entries, or a template naming one output per entry.
  • options โ€” mostly passed straight through to esbuild.

Options

Option Meaning
sourcemap Emit a source map. Only for the non-minified output. Default false.
minify Also emit a minified file. Default false.
justMinified Emit only the minified file. Great for production. Default false.
format iife, esm or cjs.
target e.g. es2018, es2019, esnext.
jsx transform (default) or automatic (React 17+ runtime).

Tip

minify: true with justMinified: false emits both scripts.js and scripts.min.js in one pass โ€” because everyone forgets to build the minified bundle for production.

Multiple scripts

Pass an array to bundle several entries:

{
  "scripts": [
    { "in": "src/js/main.ts", "out": "dist/js/scripts.js",
      "options": { "minify": true, "format": "iife", "target": "es2019" } },
    { "in": "src/js/admin.ts", "out": "dist/js/admin.js",
      "options": { "minify": true, "format": "iife", "target": "es2019" } }
  ]
}

Globs and multiple entry files

in also accepts a glob pattern or an array of entry files โ€” each becomes its own bundle, handy for per-page scripts or theme sections:

{
  "scripts": [
    { "in": "src/js/pages/*.js", "out": "dist/js/",
      "options": { "minify": true, "format": "iife" } }
  ]
}

Arrays and globs mix freely:

{ "in": ["src/js/main.ts", "src/js/pages/*.ts"], "out": "dist/js/" }

Brace alternates count as a glob on their own, so a pattern needs no * to match "whichever extension this entry happens to use":

{ "in": "src/js/pages/*.{js,ts,jsx,tsx}", "out": "dist/js/" }

Note

With more than one entry file, out must be a directory. Entry points from different directories nest their output under their common ancestor โ€” src/js/a/main.js and src/js/b/main.js become dist/js/a/main.js and dist/js/b/main.js, so same-named entries never collide. Glob patterns always use / as the separator, even on Windows.

One bundle per component directory

Libraries of components usually give each component a directory, which makes every entry point index.* โ€” and nesting under the common ancestor would bury each bundle a level deep. A glob-matched index.* is named after the directory holding it instead:

{ "in": "src/elements/*/index.{js,mjs,cjs,jsx,ts,tsx}", "out": "dist/js/" }
src/elements/accordion/index.ts  โ†’  dist/js/accordion.js
src/elements/tabs/index.ts       โ†’  dist/js/tabs.js

Add a component directory, get a bundle โ€” no config change. The rename only applies to entries a glob matched: a literal "in": "src/index.ts" still writes index.js, and an explicit out file path always wins.

The name is placed relative to the glob's static prefix โ€” everything before the first *, {โ€ฆ} or other magic segment. Above that prefix is src/elements, which every match shares, so nothing is left to nest under and the output is flat. Widen the glob and the part it no longer pins down is kept, so same-named components stay apart:

{ "in": "src/*/accordion/index.ts", "out": "dist/js/" }
src/blocks/accordion/index.ts    โ†’  dist/js/blocks/accordion.js
src/elements/accordion/index.ts  โ†’  dist/js/elements/accordion.js

Note

The prefix comes from the pattern, not from what matched, so the layout doesn't shift when you add or remove a component. To place the bundles somewhere else, move the magic segment โ€” a narrower glob per group with its own out gives you full control.

Naming outputs yourself

The index.* rule only rescues entry points actually named index. Everything else keeps its own basename, nested under the common ancestor. When you want the bundles named something else, out can be a template:

  • {{dir}} โ€” the match's directory, relative to the glob's static prefix (the same name an index.* entry would get)
  • {{name}} โ€” the match's basename without extension
{ "in": "src/elements/*/widget.ts", "out": "dist/js/{{dir}}-{{name}}.js" }
src/elements/accordion/widget.ts  โ†’  dist/js/accordion-widget.js
src/elements/tabs/widget.ts       โ†’  dist/js/tabs-widget.js

One bundle per match, named by you rather than by the common ancestor. Tokens may carry spaces ({{ dir }}) and can sit in directory segments, so "out": "dist/js/{{dir}}/widget.js" writes dist/js/accordion/widget.js. For a literal entry, {{dir}} is that entry's own directory name, so arrays mixing globs and plain paths keep working.

An extension in the template is honoured, which is the cheap way to ship one format per entry:

{ "in": "src/elements/*/index.ts", "out": "dist/esm/{{dir}}.mjs",
  "options": { "format": "esm" } }

Note

A template wins over the index.* rename โ€” you named the outputs, so nothing renames them behind your back. A template that can't tell two matches apart ("out": "dist/js/{{name}}.js" across component directories) fails the build with esbuild's "Two output files share the same path" rather than overwriting โ€” that's what {{dir}} is for. Styles take the same templates โ€” see Transpiling CSS.

Maintaining a JS library

Poops is genuinely good at library work: author once in TypeScript, ship every module format your users need. The trick is one scripts entry per target format, all reading the same entry file.

{
  "scripts": [
    { "in": "src/index.ts", "out": "dist/mylib.esm.js",
      "options": { "format": "esm", "target": "es2019", "minify": true } },
    { "in": "src/index.ts", "out": "dist/mylib.cjs.js",
      "options": { "format": "cjs", "target": "es2019", "minify": true } },
    { "in": "src/index.ts", "out": "dist/mylib.global.js",
      "options": { "format": "iife", "target": "es2019", "minify": true } }
  ],
  "banner": "/* {{ name }} v{{ version }} | {{ homepage }} | {{ license }} License */"
}

That gives you:

  • TypeScript โ†’ vanilla JS โ€” esbuild strips the types and downlevels to your target.
  • ESM for modern bundlers and <script type="module">.
  • CJS for require() in Node.
  • IIFE (global) for a plain <script> tag with a global variable.

Wire the outputs into package.json so consumers get the right one automatically:

{
  "main": "dist/mylib.cjs.js",
  "module": "dist/mylib.esm.js",
  "browser": "dist/mylib.global.min.js",
  "types": "dist/index.d.ts"
}

Note

esbuild does not emit .d.ts type declarations. If you ship types, generate them separately with tsc --emitDeclarationOnly. That is a one-line npm script beside your Poops build.

Info

The banner option stamps a comment on top of every output, templated from your package.json โ€” name, version, homepage, license, author, description. See the configuration reference for details.

Next: Transpiling CSS.