ember.nvp 1.0.0 → 1.2.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.
Files changed (48) hide show
  1. package/.github/workflows/ci.yml +6 -0
  2. package/.release-plan.json +11 -15
  3. package/CHANGELOG.md +47 -0
  4. package/package.json +1 -1
  5. package/packages/rolldown/README.md +101 -1
  6. package/packages/rolldown/package.json +13 -4
  7. package/packages/rolldown/rolldown.config.js +5 -3
  8. package/packages/rolldown/src/app-reexports.test.ts +220 -0
  9. package/packages/rolldown/src/app-reexports.ts +152 -0
  10. package/packages/rolldown/src/babel.test.ts +17 -0
  11. package/packages/rolldown/src/babel.ts +21 -3
  12. package/packages/rolldown/src/config.ts +1 -1
  13. package/packages/rolldown/src/dts.test.ts +366 -0
  14. package/packages/rolldown/src/transform.test.ts +208 -0
  15. package/packages/rolldown/src/transform.ts +64 -31
  16. package/src/bases/minimal-extension/extension.test.ts +251 -0
  17. package/src/bases/minimal-extension/files/.env.development +8 -0
  18. package/src/bases/minimal-extension/files/README.md +36 -0
  19. package/src/bases/minimal-extension/files/app/app.ts +21 -0
  20. package/src/bases/minimal-extension/files/app/boot.ts +8 -0
  21. package/src/bases/minimal-extension/files/app/config.ts +22 -0
  22. package/src/bases/minimal-extension/files/app/router.ts +10 -0
  23. package/src/bases/minimal-extension/files/app/templates/application.gts +5 -0
  24. package/src/bases/minimal-extension/files/babel.config.js +49 -0
  25. package/src/bases/minimal-extension/files/index.html +17 -0
  26. package/src/bases/minimal-extension/files/package.json +58 -0
  27. package/src/bases/minimal-extension/files/public/manifest.json +9 -0
  28. package/src/bases/minimal-extension/files/tsconfig.json +13 -0
  29. package/src/bases/minimal-extension/files/vite.config.mjs +6 -0
  30. package/src/bases/minimal-extension/index.js +142 -0
  31. package/src/cli/generator.js +4 -0
  32. package/src/cli/questions/project-type.js +9 -2
  33. package/src/layers/renovate/index.js +4 -3
  34. package/src/layers/typescript/index.js +6 -0
  35. package/src/utils/types.ts +1 -1
  36. package/test/ember.nvp/base-library.test.ts +39 -32
  37. package/test/ember.nvp/layer-publish-checks.test.ts +4 -13
  38. package/test/ember.nvp/layer-qunit.test.ts +13 -30
  39. package/test/ember.nvp/layer-vitest.test.ts +4 -18
  40. package/test/ember.nvp/permutations/app-checks.test.ts +3 -0
  41. package/test/ember.nvp/permutations/extension-1.test.ts +3 -0
  42. package/test/ember.nvp/permutations/extension-2.test.ts +3 -0
  43. package/test/ember.nvp/permutations/extension-checks.test.ts +3 -0
  44. package/test/ember.nvp/permutations/library-checks.test.ts +3 -0
  45. package/test/ember.nvp/permutations/shared.ts +68 -19
  46. package/test/ember.nvp/scoped-css-library.test.ts +211 -0
  47. package/test/test-helpers.ts +25 -30
  48. package/vitest.config.js +1 -1
@@ -45,6 +45,12 @@ jobs:
45
45
  args: test/ember.nvp/permutations/library-1.test.ts
46
46
  - name: library permutations 2/2
47
47
  args: test/ember.nvp/permutations/library-2.test.ts
48
+ - name: extension permutations 1/2
49
+ args: test/ember.nvp/permutations/extension-1.test.ts
50
+ - name: extension permutations 2/2
51
+ args: test/ember.nvp/permutations/extension-2.test.ts
52
+ - name: check-layer permutations
53
+ args: test/ember.nvp/permutations/app-checks.test.ts test/ember.nvp/permutations/library-checks.test.ts test/ember.nvp/permutations/extension-checks.test.ts
48
54
  - name: everything else
49
55
  args: --exclude 'test/ember.nvp/permutations/**'
50
56
 
@@ -1,15 +1,11 @@
1
1
  {
2
2
  "solution": {
3
3
  "ember.nvp": {
4
- "impact": "major",
5
- "oldVersion": "0.7.0",
6
- "newVersion": "1.0.0",
4
+ "impact": "minor",
5
+ "oldVersion": "1.1.0",
6
+ "newVersion": "1.2.0",
7
7
  "tagName": "latest",
8
8
  "constraints": [
9
- {
10
- "impact": "major",
11
- "reason": "Appears in changelog section :boom: Breaking Change"
12
- },
13
9
  {
14
10
  "impact": "minor",
15
11
  "reason": "Appears in changelog section :rocket: Enhancement"
@@ -21,18 +17,18 @@
21
17
  "oldVersion": "1.1.0"
22
18
  },
23
19
  "@nullvoxpopuli/ember-rolldown": {
24
- "impact": "major",
25
- "oldVersion": "1.1.0",
26
- "newVersion": "2.0.0",
20
+ "impact": "minor",
21
+ "oldVersion": "2.1.0",
22
+ "newVersion": "2.2.0",
27
23
  "tagName": "latest",
28
24
  "constraints": [
29
- {
30
- "impact": "major",
31
- "reason": "Appears in changelog section :boom: Breaking Change"
32
- },
33
25
  {
34
26
  "impact": "minor",
35
27
  "reason": "Appears in changelog section :rocket: Enhancement"
28
+ },
29
+ {
30
+ "impact": "patch",
31
+ "reason": "Appears in changelog section :bug: Bug Fix"
36
32
  }
37
33
  ],
38
34
  "pkgJSONPath": "./packages/rolldown/package.json"
@@ -41,5 +37,5 @@
41
37
  "oldVersion": "1.1.0"
42
38
  }
43
39
  },
44
- "description": "## Release (2026-07-18)\n\n* ember.nvp 1.0.0 (major)\n* @nullvoxpopuli/ember-rolldown 2.0.0 (major)\n\n#### :boom: Breaking Change\n* `@nullvoxpopuli/ember-rolldown`, `ember.nvp`\n * [#86](https://github.com/NullVoxPopuli/ember.nvp/pull/86) Remove defineConfig ([@NullVoxPopuli](https://github.com/NullVoxPopuli))\n\n#### :rocket: Enhancement\n* `@nullvoxpopuli/ember-rolldown`, `ember.nvp`\n * [#84](https://github.com/NullVoxPopuli/ember.nvp/pull/84) Create config plugin for rolldown ([@NullVoxPopuli](https://github.com/NullVoxPopuli))\n\n#### Committers: 1\n- [@NullVoxPopuli](https://github.com/NullVoxPopuli)\n"
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"
45
41
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,52 @@
1
1
  # Changelog
2
2
 
3
+ ## Release (2026-07-22)
4
+
5
+ * ember.nvp 1.2.0 (minor)
6
+ * @nullvoxpopuli/ember-rolldown 2.2.0 (minor)
7
+
8
+ #### :rocket: Enhancement
9
+ * `@nullvoxpopuli/ember-rolldown`, `ember.nvp`
10
+ * [#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))
11
+
12
+ #### :bug: Bug Fix
13
+ * `@nullvoxpopuli/ember-rolldown`
14
+ * [#99](https://github.com/NullVoxPopuli/ember.nvp/pull/99) rolldown: fix virtual .gts declaration resolution + specifier-rewrite sourcemaps ([@NullVoxPopuli](https://github.com/NullVoxPopuli))
15
+
16
+ #### Committers: 2
17
+ - @NullVoxPopuli's reduced-access machine account for AI usage ([@NullVoxPopuli-ai-agent](https://github.com/NullVoxPopuli-ai-agent))
18
+ - [@NullVoxPopuli](https://github.com/NullVoxPopuli)
19
+
20
+ ## Release (2026-07-22)
21
+
22
+ * ember.nvp 1.1.0 (minor)
23
+ * @nullvoxpopuli/ember-rolldown 2.1.0 (minor)
24
+
25
+ #### :rocket: Enhancement
26
+ * `@nullvoxpopuli/ember-rolldown`
27
+ * [#97](https://github.com/NullVoxPopuli/ember.nvp/pull/97) Add appReexports plugin as a separate ember-rolldown entrypoint ([@NullVoxPopuli-ai-agent](https://github.com/NullVoxPopuli-ai-agent))
28
+ * [#95](https://github.com/NullVoxPopuli/ember.nvp/pull/95) enable declaration maps in the rolldown plugin ([@NullVoxPopuli](https://github.com/NullVoxPopuli))
29
+ * `ember.nvp`
30
+ * [#91](https://github.com/NullVoxPopuli/ember.nvp/pull/91) Add a browser extension project type (minimal-extension base) ([@NullVoxPopuli-ai-agent](https://github.com/NullVoxPopuli-ai-agent))
31
+
32
+ #### :memo: Documentation
33
+ * `@nullvoxpopuli/ember-rolldown`, `ember.nvp`
34
+ * [#96](https://github.com/NullVoxPopuli/ember.nvp/pull/96) rolldown: document + test co-located CSS support ([@NullVoxPopuli](https://github.com/NullVoxPopuli))
35
+
36
+ #### :house: Internal
37
+ * `@nullvoxpopuli/ember-rolldown`, `ember.nvp`
38
+ * [#93](https://github.com/NullVoxPopuli/ember.nvp/pull/93) More tests ([@NullVoxPopuli](https://github.com/NullVoxPopuli))
39
+ * `ember.nvp`
40
+ * [#92](https://github.com/NullVoxPopuli/ember.nvp/pull/92) Move check layers into their own permutation matrix ([@NullVoxPopuli-ai-agent](https://github.com/NullVoxPopuli-ai-agent))
41
+ * [#90](https://github.com/NullVoxPopuli/ember.nvp/pull/90) Remove pinYukuParser test helper ([@Copilot](https://github.com/apps/copilot-swe-agent))
42
+ * [#89](https://github.com/NullVoxPopuli/ember.nvp/pull/89) refactor(test): extract shared listFiles/read helpers to #test-helpers ([@Copilot](https://github.com/apps/copilot-swe-agent))
43
+ * [#87](https://github.com/NullVoxPopuli/ember.nvp/pull/87) Convert Greeting declaration check to inline snapshot assertion ([@Copilot](https://github.com/apps/copilot-swe-agent))
44
+
45
+ #### Committers: 3
46
+ - @NullVoxPopuli's reduced-access machine account for AI usage ([@NullVoxPopuli-ai-agent](https://github.com/NullVoxPopuli-ai-agent))
47
+ - Copilot [Bot] ([@copilot-swe-agent](https://github.com/apps/copilot-swe-agent))
48
+ - [@NullVoxPopuli](https://github.com/NullVoxPopuli)
49
+
3
50
  ## Release (2026-07-18)
4
51
 
5
52
  * ember.nvp 1.0.0 (major)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ember.nvp",
3
- "version": "1.0.0",
3
+ "version": "1.2.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",
@@ -82,6 +82,60 @@ import type { TOC } from "@ember/component/template-only";
82
82
  export const Badge: TOC<BadgeSignature> = <template>...</template>;
83
83
  ```
84
84
 
85
+ ## CSS
86
+
87
+ Components that import co-located CSS (`import './popup.css'`) need
88
+ [`@tsdown/css`](https://www.npmjs.com/package/@tsdown/css) installed in your
89
+ library — tsdown auto-detects it and bundles every imported stylesheet into a
90
+ single CSS file in `dist/`. Install the version matching your `tsdown`
91
+ version (they're released in lockstep):
92
+
93
+ ```bash
94
+ npm add --save-dev @tsdown/css
95
+ ```
96
+
97
+ Without it, tsdown's css-guard fails the build on the first CSS import — and
98
+ because the importing component module never loads, any declaration that
99
+ references that component dangles, surfacing as misleading
100
+ `UNLOADABLE_DEPENDENCY` errors on `<component>.d.ts` files.
101
+
102
+ ### ember-scoped-css
103
+
104
+ [ember-scoped-css](https://github.com/auditboard/ember-scoped-css) works with
105
+ this pipeline: its template transform rides along via `ember()`'s
106
+ `babel.templateTransforms` option, and its unplugin (`ember-scoped-css/rollup`)
107
+ resolves the scoped CSS requests the transform injects:
108
+
109
+ ```js
110
+ import { defineConfig } from "tsdown";
111
+ import { ember } from "@nullvoxpopuli/ember-rolldown";
112
+ import { scopedCSS } from "ember-scoped-css/rollup";
113
+ import { scopedCSS as scopedCssBabel } from "ember-scoped-css/babel";
114
+
115
+ export default defineConfig({
116
+ entry: ["./src/index.ts"],
117
+ css: { inject: true },
118
+ plugins: [
119
+ ember({
120
+ babel: {
121
+ plugins: [scopedCssBabel()],
122
+ templateTransforms: [scopedCssBabel.template({})],
123
+ },
124
+ }),
125
+ scopedCSS(),
126
+ ],
127
+ });
128
+ ```
129
+
130
+ This scopes co-located `.css` files, inline `<style scoped>` blocks, and the
131
+ `scopedClass` pseudo-helper (that last one is what the `babel.plugins` entry
132
+ handles — leave it off if you don't use `scopedClass` in module code).
133
+
134
+ `css.inject` matters for libraries: it keeps the `import "./style.css"`
135
+ statement in `dist/index.js`, so consuming apps pull the styles in through
136
+ the module graph — without it the bundled CSS is emitted but nothing loads
137
+ it.
138
+
85
139
  ## What `ember()` does
86
140
 
87
141
  `ember()` returns an array of rolldown plugins:
@@ -107,12 +161,58 @@ ember({
107
161
  configFile: "./babel.config.js",
108
162
  babelHelpers: "bundled",
109
163
  plugins: [],
164
+ templateTransforms: [],
110
165
  filter: { include: { imports: ["ember-concurrency"], code: [] } },
111
166
  },
112
167
  });
113
168
  ```
114
169
 
115
- Each option is documented on `BabelOptions`.
170
+ Each option is documented on `BabelOptions`. `templateTransforms` feeds
171
+ template AST transforms to the default template-compilation step; it can't be
172
+ combined with a babel config file — a config lists
173
+ `babel-plugin-ember-template-compilation` itself, so its transforms belong
174
+ there.
175
+
176
+ ## App re-exports
177
+
178
+ For libraries whose modules must appear in the consuming app's namespace
179
+ (classic resolution: `{{a-component}}`, services and helpers looked up by
180
+ name), `appReexports()` does the same job as `@embroider/addon-dev`'s
181
+ `appReexports` rollup plugin. It's a separate import because most libraries
182
+ don't need it:
183
+
184
+ ```js
185
+ import { defineConfig } from "tsdown";
186
+ import { ember } from "@nullvoxpopuli/ember-rolldown";
187
+ import { appReexports } from "@nullvoxpopuli/ember-rolldown/app-reexports";
188
+
189
+ export default defineConfig({
190
+ entry: ["./src/index.ts", "./src/services/session.ts"],
191
+ plugins: [ember(), appReexports()],
192
+ });
193
+ ```
194
+
195
+ With no arguments, top-level services (`services/*`) are re-exported — under
196
+ 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:
199
+
200
+ ```js
201
+ appReexports(); // services/*
202
+ appReexports("components/**"); // one include glob
203
+ appReexports({ include: ["services/*", "helpers/*"], exclude: [...] });
204
+ ```
205
+
206
+ For every built file matching `include` (minus `exclude` and `.d.ts` files),
207
+ it writes a module under `dist/_app_/` re-exporting from the library's own
208
+ name, and records the set in `package.json` under `ember-addon.app-js`.
209
+ `mapFilename` renames a re-export; `exports` picks which bindings it forwards
210
+ (default `["default"]`).
211
+
212
+ Unlike the embroider plugin, nothing is written unless its content actually
213
+ differs from what is on disk — `_app_/` modules are compared byte-for-byte
214
+ and `package.json` is only rewritten when the `app-js` map itself changed —
215
+ so rebuilds don't re-trigger file watchers.
116
216
 
117
217
  ## Credit
118
218
 
@@ -1,11 +1,19 @@
1
1
  {
2
2
  "name": "@nullvoxpopuli/ember-rolldown",
3
- "version": "2.0.0",
3
+ "version": "2.2.0",
4
4
  "description": "Meta plugin for building ember v2 libraries/addons with rolldown & tsdown.",
5
5
  "type": "module",
6
6
  "main": "index.ts",
7
+ "exports": {
8
+ ".": "./index.ts",
9
+ "./app-reexports": "./src/app-reexports.ts"
10
+ },
7
11
  "publishConfig": {
8
- "main": "dist/index.js"
12
+ "main": "dist/index.js",
13
+ "exports": {
14
+ ".": "./dist/index.js",
15
+ "./app-reexports": "./dist/app-reexports.js"
16
+ }
9
17
  },
10
18
  "keywords": [
11
19
  "rolldown",
@@ -52,8 +60,9 @@
52
60
  }
53
61
  },
54
62
  "devDependencies": {
55
- "rolldown": "^1.1.2",
56
- "tsdown": "^0.22.7",
63
+ "@tsdown/css": "^0.22.13",
64
+ "rolldown": "^1.2.0",
65
+ "tsdown": "^0.22.9",
57
66
  "typescript": "^6.0.3"
58
67
  }
59
68
  }
@@ -8,11 +8,13 @@ const deps = [
8
8
  ];
9
9
 
10
10
  export default defineConfig({
11
- input: "index.ts",
11
+ input: {
12
+ index: "index.ts",
13
+ "app-reexports": "src/app-reexports.ts",
14
+ },
12
15
  output: {
13
16
  format: "esm",
14
- file: "dist/index.js",
15
- codeSplitting: false,
17
+ dir: "dist",
16
18
  },
17
19
  external(id) {
18
20
  if (id.startsWith("node:")) return true;
@@ -0,0 +1,220 @@
1
+ import { mkdir, mkdtemp, readFile, rm, stat, utimes, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ import { rolldown, type Plugin } from "rolldown";
6
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
7
+
8
+ import { appReexports, type AppReexportsOptions } from "./app-reexports.ts";
9
+
10
+ /**
11
+ * The plugin reads and writes package.json relative to the current working
12
+ * directory (matching the other plugins in this package), so each test runs
13
+ * inside its own temp project.
14
+ */
15
+ describe("appReexports", () => {
16
+ let projectDir: string;
17
+ const originalCwd = process.cwd();
18
+
19
+ beforeEach(async () => {
20
+ projectDir = await mkdtemp(join(tmpdir(), "app-reexports-"));
21
+
22
+ await writeFile(
23
+ join(projectDir, "package.json"),
24
+ JSON.stringify(
25
+ {
26
+ name: "my-lib",
27
+ version: "1.0.0",
28
+ "ember-addon": { version: 2, type: "addon" },
29
+ },
30
+ null,
31
+ 2,
32
+ ) + "\n",
33
+ );
34
+
35
+ await mkdir(join(projectDir, "src/components"), { recursive: true });
36
+ await mkdir(join(projectDir, "src/utils"), { recursive: true });
37
+ await writeFile(
38
+ join(projectDir, "src/components/foo.js"),
39
+ "export default function foo() {}\n",
40
+ );
41
+ await writeFile(
42
+ join(projectDir, "src/utils/math.js"),
43
+ "export function add(a, b) { return a + b; }\n",
44
+ );
45
+
46
+ process.chdir(projectDir);
47
+ });
48
+
49
+ afterEach(async () => {
50
+ process.chdir(originalCwd);
51
+ await rm(projectDir, { recursive: true, force: true });
52
+ });
53
+
54
+ async function build(options?: string | AppReexportsOptions) {
55
+ const bundle = await rolldown({
56
+ input: {
57
+ "components/foo": "./src/components/foo.js",
58
+ "utils/math": "./src/utils/math.js",
59
+ },
60
+ plugins: [appReexports(options)],
61
+ });
62
+
63
+ await bundle.write({ dir: "dist" });
64
+ await bundle.close();
65
+ }
66
+
67
+ async function readJson(path: string) {
68
+ return JSON.parse(await readFile(path, "utf8"));
69
+ }
70
+
71
+ it("writes _app_ re-exports and records them in package.json", async () => {
72
+ await build({ include: ["components/**"] });
73
+
74
+ expect(await readFile("dist/_app_/components/foo.js", "utf8")).toMatchInlineSnapshot(`
75
+ "export { default } from "my-lib/components/foo";
76
+ "
77
+ `);
78
+
79
+ const manifest = await readJson("package.json");
80
+
81
+ expect(manifest["ember-addon"]).toMatchInlineSnapshot(`
82
+ {
83
+ "app-js": {
84
+ "./components/foo.js": "./dist/_app_/components/foo.js",
85
+ },
86
+ "type": "addon",
87
+ "version": 2,
88
+ }
89
+ `);
90
+
91
+ // utils/math.js didn't match include
92
+ await expect(stat("dist/_app_/utils/math.js")).rejects.toThrow();
93
+ });
94
+
95
+ it("accepts a string argument as the include glob", async () => {
96
+ await build("components/**");
97
+
98
+ const manifest = await readJson("package.json");
99
+
100
+ expect(Object.keys(manifest["ember-addon"]["app-js"])).toEqual(["./components/foo.js"]);
101
+ });
102
+
103
+ it("defaults to top-level services", async () => {
104
+ await mkdir(join(projectDir, "src/services/nested"), { recursive: true });
105
+ await writeFile(join(projectDir, "src/services/session.js"), "export default class {}\n");
106
+ await writeFile(join(projectDir, "src/services/nested/deep.js"), "export default class {}\n");
107
+
108
+ const bundle = await rolldown({
109
+ input: {
110
+ "components/foo": "./src/components/foo.js",
111
+ "services/session": "./src/services/session.js",
112
+ "services/nested/deep": "./src/services/nested/deep.js",
113
+ },
114
+ plugins: [appReexports()],
115
+ });
116
+
117
+ await bundle.write({ dir: "dist" });
118
+ await bundle.close();
119
+
120
+ const manifest = await readJson("package.json");
121
+
122
+ // services/* is not deep: nested services (and everything else) stay out
123
+ expect(manifest["ember-addon"]["app-js"]).toEqual({
124
+ "./services/session.js": "./dist/_app_/services/session.js",
125
+ });
126
+ });
127
+
128
+ it("respects exclude", async () => {
129
+ await build({ include: ["**/*.js"], exclude: ["utils/**"] });
130
+
131
+ const manifest = await readJson("package.json");
132
+
133
+ expect(Object.keys(manifest["ember-addon"]["app-js"])).toEqual(["./components/foo.js"]);
134
+ });
135
+
136
+ it("supports mapFilename and custom exports", async () => {
137
+ await build({
138
+ include: ["utils/**"],
139
+ mapFilename: (name) => name.replace("math", "arithmetic"),
140
+ exports: () => ["add"],
141
+ });
142
+
143
+ expect(await readFile("dist/_app_/utils/arithmetic.js", "utf8")).toMatchInlineSnapshot(`
144
+ "export { add } from "my-lib/utils/math";
145
+ "
146
+ `);
147
+
148
+ const manifest = await readJson("package.json");
149
+
150
+ expect(manifest["ember-addon"]["app-js"]).toMatchInlineSnapshot(`
151
+ {
152
+ "./utils/arithmetic.js": "./dist/_app_/utils/arithmetic.js",
153
+ }
154
+ `);
155
+ });
156
+
157
+ it("never re-exports .d.ts files", async () => {
158
+ const emitDeclaration: Plugin = {
159
+ name: "test:emit-declaration",
160
+ generateBundle() {
161
+ this.emitFile({
162
+ type: "asset",
163
+ fileName: "components/foo.d.ts",
164
+ source: "declare const foo: () => void;\nexport default foo;\n",
165
+ });
166
+ },
167
+ };
168
+
169
+ const bundle = await rolldown({
170
+ input: { "components/foo": "./src/components/foo.js" },
171
+ plugins: [emitDeclaration, appReexports({ include: ["**/*"] })],
172
+ });
173
+
174
+ await bundle.write({ dir: "dist" });
175
+ await bundle.close();
176
+
177
+ const manifest = await readJson("package.json");
178
+
179
+ expect(Object.keys(manifest["ember-addon"]["app-js"])).toEqual(["./components/foo.js"]);
180
+ await expect(stat("dist/_app_/components/foo.d.ts")).rejects.toThrow();
181
+ });
182
+
183
+ it("does not touch files whose content would be unchanged", async () => {
184
+ await build({ include: ["components/**"] });
185
+
186
+ const epoch = new Date(0);
187
+
188
+ await utimes("package.json", epoch, epoch);
189
+ await utimes("dist/_app_/components/foo.js", epoch, epoch);
190
+
191
+ await build({ include: ["components/**"] });
192
+
193
+ expect((await stat("package.json")).mtimeMs).toBe(0);
194
+ expect((await stat("dist/_app_/components/foo.js")).mtimeMs).toBe(0);
195
+ });
196
+
197
+ it("leaves an equivalent package.json alone, regardless of formatting", async () => {
198
+ await build({ include: ["components/**"] });
199
+
200
+ // Same app-js map, different formatting: the plugin must not reformat.
201
+ const reformatted =
202
+ JSON.stringify(await readJson("package.json"), null, 4).replace(/\n/g, "\r\n") + "\r\n";
203
+
204
+ await writeFile("package.json", reformatted);
205
+ await build({ include: ["components/**"] });
206
+
207
+ expect(await readFile("package.json", "utf8")).toBe(reformatted);
208
+ });
209
+
210
+ it("rewrites package.json when the app-js map changes", async () => {
211
+ await build({ include: ["components/**"] });
212
+ await build({ include: ["utils/**"] });
213
+
214
+ const manifest = await readJson("package.json");
215
+
216
+ expect(manifest["ember-addon"]["app-js"]).toEqual({
217
+ "./utils/math.js": "./dist/_app_/utils/math.js",
218
+ });
219
+ });
220
+ });
@@ -0,0 +1,152 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, extname, join, matchesGlob, relative, resolve, sep } from "node:path";
3
+
4
+ import type { Plugin } from "rolldown";
5
+
6
+ export interface AppReexportsOptions {
7
+ /**
8
+ * Globs, matched against each built filename (relative to the output
9
+ * directory), selecting which modules get an app re-export.
10
+ *
11
+ * Defaults to `["services/*"]` -- top-level services only. Under strict
12
+ * mode, components and helpers are imported, but services are still
13
+ * injected by name, so they are the one thing an app must always be able
14
+ * to resolve.
15
+ *
16
+ * `.d.ts` files never match.
17
+ */
18
+ include?: string[];
19
+
20
+ /**
21
+ * Globs removing files that `include` matched.
22
+ */
23
+ exclude?: string[];
24
+
25
+ /**
26
+ * Rename a re-export: receives the built filename, returns the filename the
27
+ * app sees. Defaults to the built filename unchanged.
28
+ */
29
+ mapFilename?: (filename: string) => string;
30
+
31
+ /**
32
+ * Which bindings a file's re-export module forwards: an array of names
33
+ * (default `["default"]`), or a raw export clause string such as `"*"`.
34
+ */
35
+ exports?: (filename: string) => string[] | string | undefined;
36
+ }
37
+
38
+ /**
39
+ * Merges built modules into the consuming app's namespace, so classic
40
+ * resolution (`{{a-component}}`, service/helper lookup by name) finds them --
41
+ * the same job as `@embroider/addon-dev`'s `appReexports` rollup plugin.
42
+ *
43
+ * For every built file matching `include` (minus `exclude` and `.d.ts`), it
44
+ * writes a tiny module under `<outDir>/_app_/` re-exporting from the
45
+ * library's own name, and records the full set in `package.json` under
46
+ * `ember-addon.app-js`.
47
+ *
48
+ * Unlike the embroider plugin, nothing is written unless its content actually
49
+ * differs from what is on disk: the `_app_/` modules are compared
50
+ * byte-for-byte, and `package.json` is only rewritten when the `app-js` map
51
+ * itself changed (so an equivalent map in different formatting is left
52
+ * alone). This keeps rebuilds from re-triggering file watchers.
53
+ *
54
+ * This is a separate import, because most libraries don't need it:
55
+ *
56
+ * ```js
57
+ * import { defineConfig } from "tsdown";
58
+ * import { ember } from "@nullvoxpopuli/ember-rolldown";
59
+ * import { appReexports } from "@nullvoxpopuli/ember-rolldown/app-reexports";
60
+ *
61
+ * export default defineConfig({
62
+ * entry: ["./src/index.ts", "./src/services/session.ts"],
63
+ * plugins: [ember(), appReexports()],
64
+ * });
65
+ * ```
66
+ *
67
+ * With no arguments, top-level services (`services/*`) are re-exported. A
68
+ * string argument is an include glob; an object gives full control:
69
+ *
70
+ * ```js
71
+ * appReexports(); // services/*
72
+ * appReexports("components/**"); // one include glob
73
+ * appReexports({ include: ["services/*", "helpers/*"], exclude: [...] });
74
+ * ```
75
+ */
76
+ export function appReexports(options: string | AppReexportsOptions = {}): Plugin {
77
+ const resolved = typeof options === "string" ? { include: [options] } : options;
78
+ const include = resolved.include ?? ["services/*"];
79
+
80
+ return {
81
+ name: "ember:app-reexports",
82
+
83
+ writeBundle(outputOptions, bundle) {
84
+ const manifest = readJsonSync("package.json");
85
+ const outDir = outputOptions.dir ?? dirname(outputOptions.file ?? "dist");
86
+ const appJs: Record<string, string> = {};
87
+
88
+ // Sorted so the app-js map (and therefore package.json) is stable
89
+ // across builds regardless of chunk emission order.
90
+ for (const builtFilename of Object.keys(bundle).sort()) {
91
+ if (!isIncluded(builtFilename, include, resolved.exclude)) continue;
92
+
93
+ const appFilename = resolved.mapFilename?.(builtFilename) ?? builtFilename;
94
+ const names = resolved.exports?.(builtFilename) || ["default"];
95
+ const clause = typeof names === "string" ? names : `{ ${names.join(", ")} }`;
96
+ const withoutExtension = builtFilename.slice(0, -extname(builtFilename).length);
97
+ const reexportPath = join(outDir, "_app_", appFilename);
98
+
99
+ appJs[`./${appFilename}`] = `./${toPosix(relative(process.cwd(), resolve(reexportPath)))}`;
100
+
101
+ writeFileIfChanged(
102
+ reexportPath,
103
+ `export ${clause} from "${manifest.name}/${withoutExtension}";\n`,
104
+ );
105
+ }
106
+
107
+ const existing = manifest["ember-addon"]?.["app-js"] ?? {};
108
+
109
+ if (!sameStringRecord(existing, appJs)) {
110
+ manifest["ember-addon"] = { ...manifest["ember-addon"], "app-js": appJs };
111
+ writeFileSync("package.json", JSON.stringify(manifest, null, 2) + "\n");
112
+ }
113
+ },
114
+ };
115
+ }
116
+
117
+ function readJsonSync(path: string) {
118
+ return JSON.parse(readFileSync(path, { encoding: "utf8" }));
119
+ }
120
+
121
+ function isIncluded(filename: string, include: string[], exclude?: string[]): boolean {
122
+ if (matchesGlob(filename, "**/*.d.ts")) return false;
123
+ if (!include.some((glob) => matchesGlob(filename, glob))) return false;
124
+ if (exclude?.some((glob) => matchesGlob(filename, glob))) return false;
125
+
126
+ return true;
127
+ }
128
+
129
+ function writeFileIfChanged(path: string, content: string) {
130
+ let existing: string | undefined;
131
+
132
+ try {
133
+ existing = readFileSync(path, { encoding: "utf8" });
134
+ } catch {
135
+ // Doesn't exist yet (or isn't readable) -- write it.
136
+ }
137
+
138
+ if (existing === content) return;
139
+
140
+ mkdirSync(dirname(path), { recursive: true });
141
+ writeFileSync(path, content);
142
+ }
143
+
144
+ function sameStringRecord(a: Record<string, string>, b: Record<string, string>): boolean {
145
+ const aKeys = Object.keys(a);
146
+
147
+ return aKeys.length === Object.keys(b).length && aKeys.every((key) => a[key] === b[key]);
148
+ }
149
+
150
+ function toPosix(path: string): string {
151
+ return path.split(sep).join("/");
152
+ }