@nullvoxpopuli/ember-rolldown
v2.7.0
Published
Meta plugin for building ember v2 libraries/addons with rolldown & tsdown.
Maintainers
Readme
@nullvoxpopuli/ember-rolldown
A batteries-included meta-plugin for building Ember v2 libraries (addons) with
rolldown — or tsdown, which is
built on top of rolldown and also emits .d.ts files.
It compiles .gts/.gjs and template-tag (<template>) source into
publishable output, so a single ember() call replaces the usual stack of
@embroider/* externals handling, content-tag preprocessing, and babel wiring.
Install
npm add --save-dev @nullvoxpopuli/ember-rolldownRequires node 24+, and — since these packages ship TypeScript source — a
modern TypeScript when type-checking: 6+ with lib covering es2025 (e.g.
esnext).
Usage
Import defineConfig from the bundler you're using — tsdown or rolldown —
so it carries that tool's own config types; @nullvoxpopuli/ember-rolldown
provides ember() (the defaults travel with the plugin, not defineConfig).
In your tsdown.config.js (recommended — emits declarations):
import { defineConfig } from "tsdown";
import { ember } from "@nullvoxpopuli/ember-rolldown";
export default defineConfig({
entry: ["./src/index.ts"],
plugins: [ember()],
});This builds your entry to dist/*.js and dist/*.d.ts with sourcemaps,
cleaning dist/ between builds and leaving the ember virtual packages to the
consuming app. You choose the entry and plugins; any tsdown option you
pass wins.
Or in a plain rolldown.config.js:
import { defineConfig } from "rolldown";
import { ember } from "@nullvoxpopuli/ember-rolldown";
export default defineConfig({
input: ["src/index.ts"],
plugins: [ember()],
});A babel config is optional. Without one, ember() compiles templates
(to precompileTemplate), decorators (via
decorator-transforms), and
TypeScript; with one, your config runs instead. A babel.publish.config.* is
preferred over a babel.config.* — see
Publish vs. development babel config.
Entrypoints
Entries may be any extension — .ts, .js, .gts, .gjs:
export default defineConfig({
entry: ["./src/index.ts", "./src/components/menu.gts"],
plugins: [ember()],
});Whatever the source extension, the emitted .js and .d.ts paths mirror the
entry paths (that's how tsdown's dts support works): the example emits
dist/index.js + dist/index.d.ts and dist/components/menu.js +
dist/components/menu.d.ts. Type imports resolve against those emitted
paths, so your entries — together with your exports map — are your
public API: a module that isn't an entry has no stable dist/ path of its
own and is only reachable through the entrypoints that re-export it.
Declarations
Declarations are emitted with isolated declarations — the only declaration
pipeline that can see <template> (.gts/.gjs) modules, which exist only
inside the bundler's module graph. The tsconfig your build uses must enable it
(ember() errors otherwise):
{
"compilerOptions": {
"isolatedDeclarations": true,
},
}Isolated declarations means every exported value carries an explicit type annotation. For template-only components:
import type { TOC } from "@ember/component/template-only";
export const Badge: TOC<BadgeSignature> = <template>...</template>;Keeping the constraint off dev-only code
isolatedDeclarations is a constraint on how published code is written, so it
should cover only the code you emit declarations for. If your package also holds
dev-only code — a demo app, in-package tests — putting the flag on the single
tsconfig.json that covers everything would force explicit annotations on demo
components and test helpers that never get a .d.ts emitted for them.
The tsconfig ember() checks is the one
tsdown's tsconfig option points at, so
point the build at a publish-only config and leave tsconfig.json alone for
editors and tsc --noEmit:
// tsdown.config.js
export default defineConfig({
entry: ["./src/index.ts"],
// include: ["src/**/*"], isolatedDeclarations: true
tsconfig: "./tsconfig.publish.json",
plugins: [ember()],
});Any path works, so the publish config can live wherever you keep build
configuration — tsconfig: "./config/tsconfig.publish.json". (A directory works
too: tsconfig: "./config" picks up config/tsconfig.json.)
tsconfig: false is rejected while declarations are on: with no tsconfig there
is no isolatedDeclarations, and the fallback pipeline can't see compiled
.gts, so it would fail later with "Source file not found". Set dts: false
alongside it if the library ships no types.
A tsconfig's relative paths resolve against the file itself, so a config kept in
config/ wants "include": ["../src/**/*"] and "rootDir": "../src". The build
won't tell you if you get that wrong — tsdown drives declaration emit from
entry, not from the tsconfig's include — but tsc/ember-tsc and your
editor will, if you ever point them at that config.
The tradeoff: isolated-declaration errors in src then surface when you build
rather than in your editor, since the editor uses tsconfig.json. Run the build
(or tsc --noEmit -p tsconfig.publish.json) in CI so nothing lands unchecked.
CSS
Components that import co-located CSS (import './popup.css') need
@tsdown/css installed in your
library — tsdown auto-detects it and bundles every imported stylesheet into a
single CSS file in dist/. Install the version matching your tsdown
version (they're released in lockstep):
npm add --save-dev @tsdown/cssWithout it, tsdown's css-guard fails the build on the first CSS import — and
because the importing component module never loads, any declaration that
references that component dangles, surfacing as misleading
UNLOADABLE_DEPENDENCY errors on <component>.d.ts files.
ember-scoped-css
ember-scoped-css works with
this pipeline: its template transform rides along via ember()'s
babel.templateTransforms option, and its unplugin (ember-scoped-css/rollup)
resolves the scoped CSS requests the transform injects:
import { defineConfig } from "tsdown";
import { ember } from "@nullvoxpopuli/ember-rolldown";
import { scopedCSS } from "ember-scoped-css/rollup";
import { scopedCSS as scopedCssBabel } from "ember-scoped-css/babel";
export default defineConfig({
entry: ["./src/index.ts"],
css: { inject: true },
plugins: [
ember({
babel: {
plugins: [scopedCssBabel()],
templateTransforms: [scopedCssBabel.template({})],
},
}),
scopedCSS(),
],
});This scopes co-located .css files, inline <style scoped> blocks, and the
scopedClass pseudo-helper (that last one is what the babel.plugins entry
handles — leave it off if you don't use scopedClass in module code).
css.inject matters for libraries: it keeps the import "./style.css"
statement in dist/index.js, so consuming apps pull the styles in through
the module graph — without it the bundled CSS is emitted but nothing loads
it.
What ember() does
ember() returns an array of rolldown plugins:
emberIsolatedDeclarations()— errors when the tsconfig the build uses (tsdown'stsconfigoption, defaulting totsconfig.json) is present withoutisolatedDeclarations: true.emberExternals()— keeps yourdependencies,peerDependencies, and the ember virtual packages (e.g.@ember/component,@glimmer/tracking, the template compiler) external, so the consuming app resolves them.emberTransform()— preprocesses<template>via content-tag and maps.gts/.gjsto.ts/.jsso rolldown understands them. Also rewrites.gtsspecifiers in emitted.d.tsfiles.emberBabel()— runs babel withbabelHelpers: "bundled", but only on the files that actually need it (template-tag, decorators, template imports); everything else stays on rolldown's fast native (oxc) transform. Uses yourbabel.publish.config.*(root orconfig/) in preference to yourbabel.config.*.
Configuration
ember({
babel: {
configFile: "./babel.config.js",
babelHelpers: "bundled",
plugins: [],
templateTransforms: [],
filter: { include: { imports: ["ember-concurrency"], code: [] } },
},
});Each option is documented on BabelOptions. templateTransforms feeds
template AST transforms to the default template-compilation step; it can't be
combined with a babel config file — a config lists
babel-plugin-ember-template-compilation itself, so its transforms belong
there.
Publish vs. development babel config
A library's plain babel.config.* is usually its development config: it
compiles @embroider/macros away, targets the wire format, and wires up
whatever the in-package demo app or test suite needs. None of that belongs in a
published artifact — macros must survive for the consuming app to evaluate, and
the wire format is private between one template compiler and one glimmer
runtime of the same version.
Babel's own resolution can't tell those apart, so ember() looks for a config
named for publishing first. With no explicit configFile, detection is:
babel.publish.config.{mjs,cjs,js,mts,cts,ts,json}in the package root- the same names in
config/ - otherwise babel's own resolution (
babel.config.*, honoringrootMode) - otherwise no config file — templates, decorators and TypeScript are still handled by the built-in defaults
So a library that keeps both configs needs no babel option at all:
my-addon/
babel.config.mjs # dev: macros compiled, wire format, test-app wiring
babel.publish.config.mjs # what ember() uses...and so does one that keeps build configuration out of its root:
my-addon/
babel.config.mjs
config/
babel.publish.config.mjs # what ember() uses
tsconfig.publish.json # see belowSet configFile explicitly to override that (configFile: false ignores config
files entirely, which is what you want when your publish config would just
restate the built-in defaults).
App re-exports
For libraries whose modules must appear in the consuming app's namespace
(classic resolution: {{a-component}}, services and helpers looked up by
name), appReexports() does the same job as @embroider/addon-dev's
appReexports rollup plugin. It's a separate import because most libraries
don't need it:
import { defineConfig } from "tsdown";
import { ember } from "@nullvoxpopuli/ember-rolldown";
import { appReexports } from "@nullvoxpopuli/ember-rolldown/app-reexports";
export default defineConfig({
entry: ["./src/index.ts", "./src/services/session.ts"],
plugins: [ember(), appReexports()],
});With no arguments, top-level services (services/*) are re-exported — under
strict mode, components and helpers are imported, but services are still
injected by name. A string or array of strings is the include glob(s),
optionally followed by the remaining options; an object gives full control:
appReexports(); // services/*
appReexports("components/**"); // one include glob
appReexports(["services/*", "helpers/*"]);
appReexports("components/**", { exclude: ["components/-private/**"] });
appReexports({ include: ["services/*", "helpers/*"], exclude: [...] });For every built file matching include (minus exclude and .d.ts files),
it writes a module under dist/_app_/ re-exporting from the library's own
name, and records the set in package.json under ember-addon.app-js.
mapFilename renames a re-export; exports picks which bindings it forwards
(default ["default"]).
Unlike the embroider plugin, nothing is written unless its content actually
differs from what is on disk — _app_/ modules are compared byte-for-byte
and package.json is only rewritten when the app-js map itself changed —
so rebuilds don't re-trigger file watchers.
Credit
The emberExternals and emberTransform plugins are derived from
embroider-build/embroider#2658.
