ember.nvp 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,13 +2,17 @@
2
2
  "solution": {
3
3
  "ember.nvp": {
4
4
  "impact": "minor",
5
- "oldVersion": "1.1.0",
6
- "newVersion": "1.2.0",
5
+ "oldVersion": "1.3.0",
6
+ "newVersion": "1.4.0",
7
7
  "tagName": "latest",
8
8
  "constraints": [
9
9
  {
10
10
  "impact": "minor",
11
11
  "reason": "Appears in changelog section :rocket: Enhancement"
12
+ },
13
+ {
14
+ "impact": "patch",
15
+ "reason": "Appears in changelog section :house: Internal"
12
16
  }
13
17
  ],
14
18
  "pkgJSONPath": "./package.json"
@@ -18,8 +22,8 @@
18
22
  },
19
23
  "@nullvoxpopuli/ember-rolldown": {
20
24
  "impact": "minor",
21
- "oldVersion": "2.1.0",
22
- "newVersion": "2.2.0",
25
+ "oldVersion": "2.5.0",
26
+ "newVersion": "2.6.0",
23
27
  "tagName": "latest",
24
28
  "constraints": [
25
29
  {
@@ -28,7 +32,7 @@
28
32
  },
29
33
  {
30
34
  "impact": "patch",
31
- "reason": "Appears in changelog section :bug: Bug Fix"
35
+ "reason": "Appears in changelog section :house: Internal"
32
36
  }
33
37
  ],
34
38
  "pkgJSONPath": "./packages/rolldown/package.json"
@@ -37,5 +41,5 @@
37
41
  "oldVersion": "1.1.0"
38
42
  }
39
43
  },
40
- "description": "## Release (2026-07-22)\n\n* ember.nvp 1.2.0 (minor)\n* @nullvoxpopuli/ember-rolldown 2.2.0 (minor)\n\n#### :rocket: Enhancement\n* `@nullvoxpopuli/ember-rolldown`, `ember.nvp`\n * [#98](https://github.com/NullVoxPopuli/ember.nvp/pull/98) Integration-test ember-scoped-css; add babel.templateTransforms ([@NullVoxPopuli-ai-agent](https://github.com/NullVoxPopuli-ai-agent))\n\n#### :bug: Bug Fix\n* `@nullvoxpopuli/ember-rolldown`\n * [#99](https://github.com/NullVoxPopuli/ember.nvp/pull/99) rolldown: fix virtual .gts declaration resolution + specifier-rewrite sourcemaps ([@NullVoxPopuli](https://github.com/NullVoxPopuli))\n\n#### Committers: 2\n- @NullVoxPopuli's reduced-access machine account for AI usage ([@NullVoxPopuli-ai-agent](https://github.com/NullVoxPopuli-ai-agent))\n- [@NullVoxPopuli](https://github.com/NullVoxPopuli)\n"
44
+ "description": "## Release (2026-08-03)\n\n* ember.nvp 1.4.0 (minor)\n* @nullvoxpopuli/ember-rolldown 2.6.0 (minor)\n\n#### :rocket: Enhancement\n* `@nullvoxpopuli/ember-rolldown`, `ember.nvp`\n * [#111](https://github.com/NullVoxPopuli/ember.nvp/pull/111) rolldown: find publish configs in config/ too ([@NullVoxPopuli](https://github.com/NullVoxPopuli))\n* `@nullvoxpopuli/ember-rolldown`\n * [#109](https://github.com/NullVoxPopuli/ember.nvp/pull/109) rolldown: prefer babel.publish.config over babel.config ([@NullVoxPopuli](https://github.com/NullVoxPopuli))\n\n#### :house: Internal\n* `@nullvoxpopuli/ember-rolldown`, `ember.nvp`\n * [#112](https://github.com/NullVoxPopuli/ember.nvp/pull/112) rolldown: test the isolated-declarations guard through generated libraries ([@NullVoxPopuli](https://github.com/NullVoxPopuli))\n\n#### Committers: 1\n- [@NullVoxPopuli](https://github.com/NullVoxPopuli)\n"
41
45
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,65 @@
1
1
  # Changelog
2
2
 
3
+ ## Release (2026-08-03)
4
+
5
+ * ember.nvp 1.4.0 (minor)
6
+ * @nullvoxpopuli/ember-rolldown 2.6.0 (minor)
7
+
8
+ #### :rocket: Enhancement
9
+ * `@nullvoxpopuli/ember-rolldown`, `ember.nvp`
10
+ * [#111](https://github.com/NullVoxPopuli/ember.nvp/pull/111) rolldown: find publish configs in config/ too ([@NullVoxPopuli](https://github.com/NullVoxPopuli))
11
+ * `@nullvoxpopuli/ember-rolldown`
12
+ * [#109](https://github.com/NullVoxPopuli/ember.nvp/pull/109) rolldown: prefer babel.publish.config over babel.config ([@NullVoxPopuli](https://github.com/NullVoxPopuli))
13
+
14
+ #### :house: Internal
15
+ * `@nullvoxpopuli/ember-rolldown`, `ember.nvp`
16
+ * [#112](https://github.com/NullVoxPopuli/ember.nvp/pull/112) rolldown: test the isolated-declarations guard through generated libraries ([@NullVoxPopuli](https://github.com/NullVoxPopuli))
17
+
18
+ #### Committers: 1
19
+ - [@NullVoxPopuli](https://github.com/NullVoxPopuli)
20
+
21
+ ## Release (2026-08-03)
22
+
23
+ * ember.nvp 1.3.0 (minor)
24
+ * @nullvoxpopuli/ember-rolldown 2.5.0 (minor)
25
+
26
+ #### :rocket: Enhancement
27
+ * `ember.nvp`
28
+ * [#106](https://github.com/NullVoxPopuli/ember.nvp/pull/106) README layer ([@tcjr](https://github.com/tcjr))
29
+ * `@nullvoxpopuli/ember-rolldown`
30
+ * [#107](https://github.com/NullVoxPopuli/ember.nvp/pull/107) rolldown: check the tsconfig the build uses, not always tsconfig.json ([@NullVoxPopuli](https://github.com/NullVoxPopuli))
31
+
32
+ #### Committers: 2
33
+ - Tom Carter ([@tcjr](https://github.com/tcjr))
34
+ - [@NullVoxPopuli](https://github.com/NullVoxPopuli)
35
+
36
+ ## Release (2026-07-24)
37
+
38
+ * @nullvoxpopuli/ember-rolldown 2.4.0 (minor)
39
+
40
+ #### :rocket: Enhancement
41
+ * `@nullvoxpopuli/ember-rolldown`
42
+ * [#104](https://github.com/NullVoxPopuli/ember.nvp/pull/104) rolldown: support .gts/.gjs build entries ([@NullVoxPopuli](https://github.com/NullVoxPopuli))
43
+
44
+ #### Committers: 1
45
+ - [@NullVoxPopuli](https://github.com/NullVoxPopuli)
46
+
47
+ ## Release (2026-07-22)
48
+
49
+ * @nullvoxpopuli/ember-rolldown 2.3.0 (minor)
50
+
51
+ #### :rocket: Enhancement
52
+ * `@nullvoxpopuli/ember-rolldown`
53
+ * [#101](https://github.com/NullVoxPopuli/ember.nvp/pull/101) appReexports: accept string[] include and options as second argument ([@NullVoxPopuli-ai-agent](https://github.com/NullVoxPopuli-ai-agent))
54
+
55
+ #### :bug: Bug Fix
56
+ * `@nullvoxpopuli/ember-rolldown`
57
+ * [#103](https://github.com/NullVoxPopuli/ember.nvp/pull/103) rolldown: externalize the modules ember-source provides (renamed-modules) ([@NullVoxPopuli](https://github.com/NullVoxPopuli))
58
+
59
+ #### Committers: 2
60
+ - @NullVoxPopuli's reduced-access machine account for AI usage ([@NullVoxPopuli-ai-agent](https://github.com/NullVoxPopuli-ai-agent))
61
+ - [@NullVoxPopuli](https://github.com/NullVoxPopuli)
62
+
3
63
  ## Release (2026-07-22)
4
64
 
5
65
  * ember.nvp 1.2.0 (minor)
package/README.md CHANGED
@@ -198,6 +198,11 @@ export default {
198
198
 
199
199
  await packageJson.addScripts({ "my-script": 'echo "Hello"' }, project.directory);
200
200
  },
201
+
202
+ // Optionally add something to the README.md file
203
+ readme() {
204
+ return "### My Feature\n\nHere's what my feature brings to the table.";
205
+ },
201
206
  };
202
207
  ```
203
208
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ember.nvp",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "ember project generator, alternative to the official blueprints, but more modern -- hopefully one day to upstream back in to ember-cli / official blueprints",
5
5
  "repository": {
6
6
  "type": "git",
@@ -53,16 +53,37 @@ export default defineConfig({
53
53
  });
54
54
  ```
55
55
 
56
- A `babel.config.js` is optional. Without one, `ember()` compiles templates
56
+ A babel config is optional. Without one, `ember()` compiles templates
57
57
  (to `precompileTemplate`), decorators (via
58
58
  [decorator-transforms](https://github.com/ef4/decorator-transforms)), and
59
- TypeScript; with one, your config runs instead.
59
+ TypeScript; with one, your config runs instead. A `babel.publish.config.*` is
60
+ preferred over a `babel.config.*` — see
61
+ [Publish vs. development babel config](#publish-vs-development-babel-config).
62
+
63
+ ### Entrypoints
64
+
65
+ Entries may be any extension — `.ts`, `.js`, `.gts`, `.gjs`:
66
+
67
+ ```js
68
+ export default defineConfig({
69
+ entry: ["./src/index.ts", "./src/components/menu.gts"],
70
+ plugins: [ember()],
71
+ });
72
+ ```
73
+
74
+ Whatever the source extension, the emitted `.js` and `.d.ts` paths mirror the
75
+ entry paths (that's how tsdown's dts support works): the example emits
76
+ `dist/index.js` + `dist/index.d.ts` and `dist/components/menu.js` +
77
+ `dist/components/menu.d.ts`. Type imports resolve against those emitted
78
+ paths, so your entries — together with your `exports` map — _are_ your
79
+ public API: a module that isn't an entry has no stable `dist/` path of its
80
+ own and is only reachable through the entrypoints that re-export it.
60
81
 
61
82
  ### Declarations
62
83
 
63
84
  Declarations are emitted with isolated declarations — the only declaration
64
85
  pipeline that can see `<template>` (`.gts`/`.gjs`) modules, which exist only
65
- inside the bundler's module graph. Your `tsconfig.json` must enable it
86
+ inside the bundler's module graph. The tsconfig your build uses must enable it
66
87
  (`ember()` errors otherwise):
67
88
 
68
89
  ```jsonc
@@ -82,6 +103,48 @@ import type { TOC } from "@ember/component/template-only";
82
103
  export const Badge: TOC<BadgeSignature> = <template>...</template>;
83
104
  ```
84
105
 
106
+ #### Keeping the constraint off dev-only code
107
+
108
+ `isolatedDeclarations` is a constraint on how _published_ code is written, so it
109
+ should cover only the code you emit declarations for. If your package also holds
110
+ dev-only code — a demo app, in-package tests — putting the flag on the single
111
+ `tsconfig.json` that covers everything would force explicit annotations on demo
112
+ components and test helpers that never get a `.d.ts` emitted for them.
113
+
114
+ The tsconfig `ember()` checks is the one
115
+ [tsdown's `tsconfig` option](https://tsdown.dev/options/tsconfig) points at, so
116
+ point the build at a publish-only config and leave `tsconfig.json` alone for
117
+ editors and `tsc --noEmit`:
118
+
119
+ ```js
120
+ // tsdown.config.js
121
+ export default defineConfig({
122
+ entry: ["./src/index.ts"],
123
+ // include: ["src/**/*"], isolatedDeclarations: true
124
+ tsconfig: "./tsconfig.publish.json",
125
+ plugins: [ember()],
126
+ });
127
+ ```
128
+
129
+ Any path works, so the publish config can live wherever you keep build
130
+ configuration — `tsconfig: "./config/tsconfig.publish.json"`. (A directory works
131
+ too: `tsconfig: "./config"` picks up `config/tsconfig.json`.)
132
+
133
+ `tsconfig: false` is rejected while declarations are on: with no tsconfig there
134
+ is no `isolatedDeclarations`, and the fallback pipeline can't see compiled
135
+ `.gts`, so it would fail later with "Source file not found". Set `dts: false`
136
+ alongside it if the library ships no types.
137
+
138
+ A tsconfig's relative paths resolve against the file itself, so a config kept in
139
+ `config/` wants `"include": ["../src/**/*"]` and `"rootDir": "../src"`. The build
140
+ won't tell you if you get that wrong — tsdown drives declaration emit from
141
+ `entry`, not from the tsconfig's `include` — but `tsc`/`ember-tsc` and your
142
+ editor will, if you ever point them at that config.
143
+
144
+ The tradeoff: isolated-declaration errors in `src` then surface when you build
145
+ rather than in your editor, since the editor uses `tsconfig.json`. Run the build
146
+ (or `tsc --noEmit -p tsconfig.publish.json`) in CI so nothing lands unchecked.
147
+
85
148
  ## CSS
86
149
 
87
150
  Components that import co-located CSS (`import './popup.css'`) need
@@ -140,8 +203,9 @@ it.
140
203
 
141
204
  `ember()` returns an array of rolldown plugins:
142
205
 
143
- - **`emberIsolatedDeclarations()`** — errors when a `tsconfig.json` is present
144
- without `isolatedDeclarations: true`.
206
+ - **`emberIsolatedDeclarations()`** — errors when the tsconfig the build uses
207
+ (tsdown's `tsconfig` option, defaulting to `tsconfig.json`) is present without
208
+ `isolatedDeclarations: true`.
145
209
  - **`emberExternals()`** — keeps your `dependencies`, `peerDependencies`, and
146
210
  the ember virtual packages (e.g. `@ember/component`, `@glimmer/tracking`, the
147
211
  template compiler) external, so the consuming app resolves them.
@@ -151,7 +215,9 @@ it.
151
215
  `.gts` specifiers in emitted `.d.ts` files.
152
216
  - **`emberBabel()`** — runs babel with `babelHelpers: "bundled"`, but only on
153
217
  the files that actually need it (template-tag, decorators, template imports);
154
- everything else stays on rolldown's fast native (oxc) transform.
218
+ everything else stays on rolldown's fast native (oxc) transform. Uses your
219
+ `babel.publish.config.*` (root or `config/`) in preference to your
220
+ `babel.config.*`.
155
221
 
156
222
  ## Configuration
157
223
 
@@ -173,6 +239,46 @@ combined with a babel config file — a config lists
173
239
  `babel-plugin-ember-template-compilation` itself, so its transforms belong
174
240
  there.
175
241
 
242
+ ### Publish vs. development babel config
243
+
244
+ A library's plain `babel.config.*` is usually its _development_ config: it
245
+ compiles `@embroider/macros` away, targets the wire format, and wires up
246
+ whatever the in-package demo app or test suite needs. None of that belongs in a
247
+ published artifact — macros must survive for the consuming app to evaluate, and
248
+ the wire format is private between one template compiler and one glimmer
249
+ runtime of the same version.
250
+
251
+ Babel's own resolution can't tell those apart, so `ember()` looks for a config
252
+ named for publishing first. With no explicit `configFile`, detection is:
253
+
254
+ 1. `babel.publish.config.{mjs,cjs,js,mts,cts,ts,json}` in the package root
255
+ 2. the same names in `config/`
256
+ 3. otherwise babel's own resolution (`babel.config.*`, honoring `rootMode`)
257
+ 4. otherwise no config file — templates, decorators and TypeScript are still
258
+ handled by the built-in defaults
259
+
260
+ So a library that keeps both configs needs no `babel` option at all:
261
+
262
+ ```
263
+ my-addon/
264
+ babel.config.mjs # dev: macros compiled, wire format, test-app wiring
265
+ babel.publish.config.mjs # what ember() uses
266
+ ```
267
+
268
+ ...and so does one that keeps build configuration out of its root:
269
+
270
+ ```
271
+ my-addon/
272
+ babel.config.mjs
273
+ config/
274
+ babel.publish.config.mjs # what ember() uses
275
+ tsconfig.publish.json # see below
276
+ ```
277
+
278
+ Set `configFile` explicitly to override that (`configFile: false` ignores config
279
+ files entirely, which is what you want when your publish config would just
280
+ restate the built-in defaults).
281
+
176
282
  ## App re-exports
177
283
 
178
284
  For libraries whose modules must appear in the consuming app's namespace
@@ -194,12 +300,14 @@ export default defineConfig({
194
300
 
195
301
  With no arguments, top-level services (`services/*`) are re-exported — under
196
302
  strict mode, components and helpers are imported, but services are still
197
- injected by name. A string argument is an include glob, and an object gives
198
- full control:
303
+ injected by name. A string or array of strings is the include glob(s),
304
+ optionally followed by the remaining options; an object gives full control:
199
305
 
200
306
  ```js
201
307
  appReexports(); // services/*
202
308
  appReexports("components/**"); // one include glob
309
+ appReexports(["services/*", "helpers/*"]);
310
+ appReexports("components/**", { exclude: ["components/-private/**"] });
203
311
  appReexports({ include: ["services/*", "helpers/*"], exclude: [...] });
204
312
  ```
205
313
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nullvoxpopuli/ember-rolldown",
3
- "version": "2.2.0",
3
+ "version": "2.6.0",
4
4
  "description": "Meta plugin for building ember v2 libraries/addons with rolldown & tsdown.",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -51,13 +51,16 @@ describe("appReexports", () => {
51
51
  await rm(projectDir, { recursive: true, force: true });
52
52
  });
53
53
 
54
- async function build(options?: string | AppReexportsOptions) {
54
+ async function build(
55
+ include?: string | string[] | AppReexportsOptions,
56
+ options?: Omit<AppReexportsOptions, "include">,
57
+ ) {
55
58
  const bundle = await rolldown({
56
59
  input: {
57
60
  "components/foo": "./src/components/foo.js",
58
61
  "utils/math": "./src/utils/math.js",
59
62
  },
60
- plugins: [appReexports(options)],
63
+ plugins: [appReexports(include, options)],
61
64
  });
62
65
 
63
66
  await bundle.write({ dir: "dist" });
@@ -100,6 +103,36 @@ describe("appReexports", () => {
100
103
  expect(Object.keys(manifest["ember-addon"]["app-js"])).toEqual(["./components/foo.js"]);
101
104
  });
102
105
 
106
+ it("accepts an array of include globs", async () => {
107
+ await build(["components/**", "utils/**"]);
108
+
109
+ const manifest = await readJson("package.json");
110
+
111
+ expect(Object.keys(manifest["ember-addon"]["app-js"])).toEqual([
112
+ "./components/foo.js",
113
+ "./utils/math.js",
114
+ ]);
115
+ });
116
+
117
+ it("accepts options as the second argument alongside a string or array include", async () => {
118
+ await build("**/*.js", { exclude: ["utils/**"] });
119
+
120
+ let manifest = await readJson("package.json");
121
+
122
+ expect(Object.keys(manifest["ember-addon"]["app-js"])).toEqual(["./components/foo.js"]);
123
+
124
+ await build(["components/**", "utils/**"], {
125
+ mapFilename: (name) => name.replace("math", "arithmetic"),
126
+ });
127
+
128
+ manifest = await readJson("package.json");
129
+
130
+ expect(manifest["ember-addon"]["app-js"]).toEqual({
131
+ "./components/foo.js": "./dist/_app_/components/foo.js",
132
+ "./utils/arithmetic.js": "./dist/_app_/utils/arithmetic.js",
133
+ });
134
+ });
135
+
103
136
  it("defaults to top-level services", async () => {
104
137
  await mkdir(join(projectDir, "src/services/nested"), { recursive: true });
105
138
  await writeFile(join(projectDir, "src/services/session.js"), "export default class {}\n");
@@ -65,16 +65,27 @@ export interface AppReexportsOptions {
65
65
  * ```
66
66
  *
67
67
  * With no arguments, top-level services (`services/*`) are re-exported. A
68
- * string argument is an include glob; an object gives full control:
68
+ * string or array of strings is the include glob(s), optionally followed by
69
+ * the remaining options; an object gives full control:
69
70
  *
70
71
  * ```js
71
72
  * appReexports(); // services/*
72
73
  * appReexports("components/**"); // one include glob
74
+ * appReexports(["services/*", "helpers/*"]);
75
+ * appReexports("components/**", { exclude: ["components/-private/**"] });
73
76
  * appReexports({ include: ["services/*", "helpers/*"], exclude: [...] });
74
77
  * ```
75
78
  */
76
- export function appReexports(options: string | AppReexportsOptions = {}): Plugin {
77
- const resolved = typeof options === "string" ? { include: [options] } : options;
79
+ export function appReexports(
80
+ includeOrOptions: string | string[] | AppReexportsOptions = {},
81
+ options: Omit<AppReexportsOptions, "include"> = {},
82
+ ): Plugin {
83
+ const resolved =
84
+ typeof includeOrOptions === "string"
85
+ ? { ...options, include: [includeOrOptions] }
86
+ : Array.isArray(includeOrOptions)
87
+ ? { ...options, include: includeOrOptions }
88
+ : includeOrOptions;
78
89
  const include = resolved.include ?? ["services/*"];
79
90
 
80
91
  return {
@@ -1,6 +1,85 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import { rolldown } from "rolldown";
5
+ import { afterEach, describe, expect, it } from "vitest";
2
6
 
3
- import { emberBabel } from "./babel.ts";
7
+ import { emberBabel, type BabelOptions } from "./babel.ts";
8
+ import { emberTransform } from "./transform.ts";
9
+
10
+ let restoreCwd: (() => void) | undefined;
11
+
12
+ afterEach(() => {
13
+ restoreCwd?.();
14
+ restoreCwd = undefined;
15
+ });
16
+
17
+ /**
18
+ * A babel config whose only plugin rewrites the string `"MARKER"` to `name`, so
19
+ * the emitted code says which config file babel actually loaded.
20
+ */
21
+ function markerConfig(name: string): string {
22
+ return [
23
+ `export default {`,
24
+ ` plugins: [`,
25
+ ` function marker() {`,
26
+ ` return {`,
27
+ ` visitor: {`,
28
+ ` StringLiteral(path) {`,
29
+ ` if (path.node.value === 'MARKER') path.node.value = '${name}';`,
30
+ ` },`,
31
+ ` },`,
32
+ ` };`,
33
+ ` },`,
34
+ ` ],`,
35
+ `};`,
36
+ ].join("\n");
37
+ }
38
+
39
+ /**
40
+ * Builds a `.gts` entry (so babel actually runs -- `maybeBabel` skips files that
41
+ * need no transform) in a temp dir that becomes the cwd, the way config
42
+ * detection sees it under the tsdown CLI. Returns the emitted code.
43
+ */
44
+ async function bundleWithBabel(
45
+ files: Record<string, string>,
46
+ options?: BabelOptions,
47
+ ): Promise<string> {
48
+ const dir = await mkdtemp(path.join(tmpdir(), "ember-rolldown-babel-"));
49
+
50
+ const withDefaults: Record<string, string> = {
51
+ "package.json": JSON.stringify({ name: "fixture", version: "0.0.0", type: "module" }),
52
+ "index.gts": [
53
+ `export const marker = 'MARKER';`,
54
+ `export default <template>hi</template>;`,
55
+ ].join("\n"),
56
+ ...files,
57
+ };
58
+
59
+ for (const [relative, source] of Object.entries(withDefaults)) {
60
+ const full = path.join(dir, relative);
61
+ await mkdir(path.dirname(full), { recursive: true });
62
+ await writeFile(full, source, "utf8");
63
+ }
64
+
65
+ const previousCwd = process.cwd();
66
+ process.chdir(dir);
67
+ restoreCwd = () => process.chdir(previousCwd);
68
+
69
+ const build = await rolldown({
70
+ input: [path.join(dir, "index.gts")],
71
+ plugins: [emberTransform(), emberBabel(options)],
72
+ external: (id) => !(id.startsWith(".") || path.isAbsolute(id)),
73
+ onwarn() {},
74
+ });
75
+
76
+ const { output } = await build.generate({ format: "es" });
77
+
78
+ return output
79
+ .filter((chunk): chunk is typeof chunk & { code: string } => "code" in chunk)
80
+ .map((chunk) => chunk.code)
81
+ .join("\n");
82
+ }
4
83
 
5
84
  describe("emberBabel", () => {
6
85
  it("rejects templateTransforms combined with a config file", () => {
@@ -14,4 +93,48 @@ describe("emberBabel", () => {
14
93
  emberBabel({ configFile: false, templateTransforms: ["some-transform"] }),
15
94
  ).not.toThrow();
16
95
  });
96
+
97
+ describe("config detection", () => {
98
+ it("prefers babel.publish.config over babel.config", async () => {
99
+ const code = await bundleWithBabel({
100
+ "babel.config.mjs": markerConfig("dev"),
101
+ "babel.publish.config.mjs": markerConfig("publish"),
102
+ });
103
+
104
+ expect(code).toContain(`"publish"`);
105
+ expect(code).not.toContain(`"dev"`);
106
+ });
107
+
108
+ it("falls back to babel.config when there is no publish config", async () => {
109
+ const code = await bundleWithBabel({ "babel.config.mjs": markerConfig("dev") });
110
+
111
+ expect(code).toContain(`"dev"`);
112
+ });
113
+
114
+ it("uses an explicit configFile over either", async () => {
115
+ const code = await bundleWithBabel(
116
+ {
117
+ "babel.config.mjs": markerConfig("dev"),
118
+ "babel.publish.config.mjs": markerConfig("publish"),
119
+ "babel.explicit.config.mjs": markerConfig("explicit"),
120
+ },
121
+ { configFile: "./babel.explicit.config.mjs" },
122
+ );
123
+
124
+ expect(code).toContain(`"explicit"`);
125
+ expect(code).not.toContain(`"publish"`);
126
+ });
127
+
128
+ it("ignores every config file when configFile is false", async () => {
129
+ const code = await bundleWithBabel(
130
+ {
131
+ "babel.config.mjs": markerConfig("dev"),
132
+ "babel.publish.config.mjs": markerConfig("publish"),
133
+ },
134
+ { configFile: false },
135
+ );
136
+
137
+ expect(code).toContain(`"MARKER"`);
138
+ });
139
+ });
17
140
  });
@@ -3,6 +3,8 @@ import transformTypeScript from "@babel/plugin-transform-typescript";
3
3
  import templateCompilation from "babel-plugin-ember-template-compilation";
4
4
  import decoratorTransforms from "decorator-transforms";
5
5
  import { loadPartialConfigSync } from "@babel/core";
6
+ import { existsSync } from "node:fs";
7
+ import { resolve } from "node:path";
6
8
  import type { PluginItem } from "@babel/core";
7
9
  import type { Transform } from "babel-plugin-ember-template-compilation";
8
10
  import type { Plugin } from "rolldown";
@@ -12,8 +14,9 @@ export interface BabelOptions {
12
14
  /**
13
15
  * The babel config file to use.
14
16
  *
15
- * - `undefined`: auto-detect the project's babel config (via babel's own
16
- * config resolution).
17
+ * - `undefined`: auto-detect. A `babel.publish.config.*` (in the package root
18
+ * or `config/`) wins, because this is a publish build; otherwise babel's own
19
+ * config resolution applies (see `detectConfigFile`).
17
20
  * - a string: use that config file.
18
21
  * - `false`: ignore config files entirely.
19
22
  *
@@ -46,14 +49,55 @@ export interface BabelOptions {
46
49
  };
47
50
  }
48
51
 
52
+ /** Tried in order, so an ESM config wins over a JSON one of the same name. */
53
+ const PUBLISH_CONFIG_EXTENSIONS = ["mjs", "cjs", "js", "mts", "cts", "ts", "json"];
54
+
55
+ /**
56
+ * Tried in order: next to package.json first, then `config/` for libraries that
57
+ * keep their build configuration out of the package root.
58
+ */
59
+ const PUBLISH_CONFIG_DIRECTORIES = [".", "config"];
60
+
49
61
  /**
50
- * Resolve the project's root babel config the way babel itself does
51
- * (`babel.config.{js,cjs,mjs,cts,json}`, honoring `rootMode` semantics).
62
+ * A `babel.publish.config.*` (in the package root or `config/`), if there is
63
+ * one.
64
+ *
65
+ * A library's plain `babel.config.*` is its *development* config: it typically
66
+ * compiles `@embroider/macros` away, targets the wire format, and wires up
67
+ * whatever the in-package demo app or test suite needs. None of that belongs in
68
+ * a published artifact -- macros must stay for the consuming app to evaluate,
69
+ * and the wire format is private between one template compiler and one glimmer
70
+ * runtime. Babel's own resolution can't tell the two apart (it finds whatever
71
+ * `babel.config.*` exists), so a publish build has to prefer the config named
72
+ * for publishing.
73
+ */
74
+ function detectPublishConfigFile(): string | undefined {
75
+ for (const directory of PUBLISH_CONFIG_DIRECTORIES) {
76
+ for (const extension of PUBLISH_CONFIG_EXTENSIONS) {
77
+ const candidate = resolve(directory, `babel.publish.config.${extension}`);
78
+
79
+ if (existsSync(candidate)) return candidate;
80
+ }
81
+ }
82
+
83
+ return undefined;
84
+ }
85
+
86
+ /**
87
+ * The config this publish build should use: a `babel.publish.config.*` when the
88
+ * library has one, else the project's root babel config resolved the way babel
89
+ * itself does (`babel.config.{js,cjs,mjs,cts,json}`, honoring `rootMode`
90
+ * semantics) -- which for a library whose only config *is* its publish config
91
+ * is the same file either way.
52
92
  *
53
93
  * The sync API evaluates the config file; on node 24+ that works for native
54
94
  * ESM configs too (require(esm)).
55
95
  */
56
96
  function detectConfigFile(): string | undefined {
97
+ const publishConfig = detectPublishConfigFile();
98
+
99
+ if (publishConfig) return publishConfig;
100
+
57
101
  const partial = loadPartialConfigSync({ cwd: process.cwd() });
58
102
 
59
103
  return partial?.config ?? undefined;
@@ -112,9 +156,10 @@ function defaultPlugins(templateTransforms?: Transform[]): PluginItem[] {
112
156
  * to only the files that actually need it (template-tag, decorators, template
113
157
  * imports). Everything else stays on the native transform.
114
158
  *
115
- * The library's own babel config is used when it exists; without one,
116
- * `defaultPlugins` covers templates, decorators, and TypeScript, so no
117
- * config file is required.
159
+ * The library's own babel config is used when it exists -- its
160
+ * `babel.publish.config.*` (root or `config/`) in preference to its
161
+ * `babel.config.*`, since this is a publish build. Without one, `defaultPlugins` covers templates, decorators,
162
+ * and TypeScript, so no config file is required.
118
163
  *
119
164
  * Libraries default to `babelHelpers: "bundled"` so the emitted output is
120
165
  * self-contained.