ember.nvp 1.0.0 → 1.1.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 (44) hide show
  1. package/.github/workflows/ci.yml +6 -0
  2. package/.release-plan.json +23 -15
  3. package/CHANGELOG.md +30 -0
  4. package/package.json +1 -1
  5. package/packages/rolldown/README.md +62 -0
  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/config.ts +1 -1
  11. package/packages/rolldown/src/dts.test.ts +166 -0
  12. package/packages/rolldown/src/transform.test.ts +173 -0
  13. package/src/bases/minimal-extension/extension.test.ts +251 -0
  14. package/src/bases/minimal-extension/files/.env.development +8 -0
  15. package/src/bases/minimal-extension/files/README.md +36 -0
  16. package/src/bases/minimal-extension/files/app/app.ts +21 -0
  17. package/src/bases/minimal-extension/files/app/boot.ts +8 -0
  18. package/src/bases/minimal-extension/files/app/config.ts +22 -0
  19. package/src/bases/minimal-extension/files/app/router.ts +10 -0
  20. package/src/bases/minimal-extension/files/app/templates/application.gts +5 -0
  21. package/src/bases/minimal-extension/files/babel.config.js +49 -0
  22. package/src/bases/minimal-extension/files/index.html +17 -0
  23. package/src/bases/minimal-extension/files/package.json +58 -0
  24. package/src/bases/minimal-extension/files/public/manifest.json +9 -0
  25. package/src/bases/minimal-extension/files/tsconfig.json +13 -0
  26. package/src/bases/minimal-extension/files/vite.config.mjs +6 -0
  27. package/src/bases/minimal-extension/index.js +142 -0
  28. package/src/cli/generator.js +4 -0
  29. package/src/cli/questions/project-type.js +9 -2
  30. package/src/layers/renovate/index.js +4 -3
  31. package/src/layers/typescript/index.js +6 -0
  32. package/src/utils/types.ts +1 -1
  33. package/test/ember.nvp/base-library.test.ts +39 -32
  34. package/test/ember.nvp/layer-publish-checks.test.ts +4 -13
  35. package/test/ember.nvp/layer-qunit.test.ts +13 -30
  36. package/test/ember.nvp/layer-vitest.test.ts +4 -18
  37. package/test/ember.nvp/permutations/app-checks.test.ts +3 -0
  38. package/test/ember.nvp/permutations/extension-1.test.ts +3 -0
  39. package/test/ember.nvp/permutations/extension-2.test.ts +3 -0
  40. package/test/ember.nvp/permutations/extension-checks.test.ts +3 -0
  41. package/test/ember.nvp/permutations/library-checks.test.ts +3 -0
  42. package/test/ember.nvp/permutations/shared.ts +68 -19
  43. package/test/test-helpers.ts +25 -30
  44. 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,18 +1,22 @@
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.0.0",
6
+ "newVersion": "1.1.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"
12
+ },
13
+ {
14
+ "impact": "patch",
15
+ "reason": "Appears in changelog section :memo: Documentation"
16
+ },
17
+ {
18
+ "impact": "patch",
19
+ "reason": "Appears in changelog section :house: Internal"
16
20
  }
17
21
  ],
18
22
  "pkgJSONPath": "./package.json"
@@ -21,18 +25,22 @@
21
25
  "oldVersion": "1.1.0"
22
26
  },
23
27
  "@nullvoxpopuli/ember-rolldown": {
24
- "impact": "major",
25
- "oldVersion": "1.1.0",
26
- "newVersion": "2.0.0",
28
+ "impact": "minor",
29
+ "oldVersion": "2.0.0",
30
+ "newVersion": "2.1.0",
27
31
  "tagName": "latest",
28
32
  "constraints": [
29
- {
30
- "impact": "major",
31
- "reason": "Appears in changelog section :boom: Breaking Change"
32
- },
33
33
  {
34
34
  "impact": "minor",
35
35
  "reason": "Appears in changelog section :rocket: Enhancement"
36
+ },
37
+ {
38
+ "impact": "patch",
39
+ "reason": "Appears in changelog section :memo: Documentation"
40
+ },
41
+ {
42
+ "impact": "patch",
43
+ "reason": "Appears in changelog section :house: Internal"
36
44
  }
37
45
  ],
38
46
  "pkgJSONPath": "./packages/rolldown/package.json"
@@ -41,5 +49,5 @@
41
49
  "oldVersion": "1.1.0"
42
50
  }
43
51
  },
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"
52
+ "description": "## Release (2026-07-22)\n\n* ember.nvp 1.1.0 (minor)\n* @nullvoxpopuli/ember-rolldown 2.1.0 (minor)\n\n#### :rocket: Enhancement\n* `@nullvoxpopuli/ember-rolldown`\n * [#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))\n * [#95](https://github.com/NullVoxPopuli/ember.nvp/pull/95) enable declaration maps in the rolldown plugin ([@NullVoxPopuli](https://github.com/NullVoxPopuli))\n* `ember.nvp`\n * [#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))\n\n#### :memo: Documentation\n* `@nullvoxpopuli/ember-rolldown`, `ember.nvp`\n * [#96](https://github.com/NullVoxPopuli/ember.nvp/pull/96) rolldown: document + test co-located CSS support ([@NullVoxPopuli](https://github.com/NullVoxPopuli))\n\n#### :house: Internal\n* `@nullvoxpopuli/ember-rolldown`, `ember.nvp`\n * [#93](https://github.com/NullVoxPopuli/ember.nvp/pull/93) More tests ([@NullVoxPopuli](https://github.com/NullVoxPopuli))\n* `ember.nvp`\n * [#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))\n * [#90](https://github.com/NullVoxPopuli/ember.nvp/pull/90) Remove pinYukuParser test helper ([@Copilot](https://github.com/apps/copilot-swe-agent))\n * [#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))\n * [#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))\n\n#### Committers: 3\n- @NullVoxPopuli's reduced-access machine account for AI usage ([@NullVoxPopuli-ai-agent](https://github.com/NullVoxPopuli-ai-agent))\n- Copilot [Bot] ([@copilot-swe-agent](https://github.com/apps/copilot-swe-agent))\n- [@NullVoxPopuli](https://github.com/NullVoxPopuli)\n"
45
53
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## Release (2026-07-22)
4
+
5
+ * ember.nvp 1.1.0 (minor)
6
+ * @nullvoxpopuli/ember-rolldown 2.1.0 (minor)
7
+
8
+ #### :rocket: Enhancement
9
+ * `@nullvoxpopuli/ember-rolldown`
10
+ * [#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))
11
+ * [#95](https://github.com/NullVoxPopuli/ember.nvp/pull/95) enable declaration maps in the rolldown plugin ([@NullVoxPopuli](https://github.com/NullVoxPopuli))
12
+ * `ember.nvp`
13
+ * [#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))
14
+
15
+ #### :memo: Documentation
16
+ * `@nullvoxpopuli/ember-rolldown`, `ember.nvp`
17
+ * [#96](https://github.com/NullVoxPopuli/ember.nvp/pull/96) rolldown: document + test co-located CSS support ([@NullVoxPopuli](https://github.com/NullVoxPopuli))
18
+
19
+ #### :house: Internal
20
+ * `@nullvoxpopuli/ember-rolldown`, `ember.nvp`
21
+ * [#93](https://github.com/NullVoxPopuli/ember.nvp/pull/93) More tests ([@NullVoxPopuli](https://github.com/NullVoxPopuli))
22
+ * `ember.nvp`
23
+ * [#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))
24
+ * [#90](https://github.com/NullVoxPopuli/ember.nvp/pull/90) Remove pinYukuParser test helper ([@Copilot](https://github.com/apps/copilot-swe-agent))
25
+ * [#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))
26
+ * [#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))
27
+
28
+ #### Committers: 3
29
+ - @NullVoxPopuli's reduced-access machine account for AI usage ([@NullVoxPopuli-ai-agent](https://github.com/NullVoxPopuli-ai-agent))
30
+ - Copilot [Bot] ([@copilot-swe-agent](https://github.com/apps/copilot-swe-agent))
31
+ - [@NullVoxPopuli](https://github.com/NullVoxPopuli)
32
+
3
33
  ## Release (2026-07-18)
4
34
 
5
35
  * 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.1.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,27 @@ 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
+ Scoping/transforming the CSS itself (e.g.
103
+ [ember-scoped-css](https://github.com/soxhub/ember-scoped-css)) is separate
104
+ from bundling and runs via its own plugins.
105
+
85
106
  ## What `ember()` does
86
107
 
87
108
  `ember()` returns an array of rolldown plugins:
@@ -114,6 +135,47 @@ ember({
114
135
 
115
136
  Each option is documented on `BabelOptions`.
116
137
 
138
+ ## App re-exports
139
+
140
+ For libraries whose modules must appear in the consuming app's namespace
141
+ (classic resolution: `{{a-component}}`, services and helpers looked up by
142
+ name), `appReexports()` does the same job as `@embroider/addon-dev`'s
143
+ `appReexports` rollup plugin. It's a separate import because most libraries
144
+ don't need it:
145
+
146
+ ```js
147
+ import { defineConfig } from "tsdown";
148
+ import { ember } from "@nullvoxpopuli/ember-rolldown";
149
+ import { appReexports } from "@nullvoxpopuli/ember-rolldown/app-reexports";
150
+
151
+ export default defineConfig({
152
+ entry: ["./src/index.ts", "./src/services/session.ts"],
153
+ plugins: [ember(), appReexports()],
154
+ });
155
+ ```
156
+
157
+ With no arguments, top-level services (`services/*`) are re-exported — under
158
+ strict mode, components and helpers are imported, but services are still
159
+ injected by name. A string argument is an include glob, and an object gives
160
+ full control:
161
+
162
+ ```js
163
+ appReexports(); // services/*
164
+ appReexports("components/**"); // one include glob
165
+ appReexports({ include: ["services/*", "helpers/*"], exclude: [...] });
166
+ ```
167
+
168
+ For every built file matching `include` (minus `exclude` and `.d.ts` files),
169
+ it writes a module under `dist/_app_/` re-exporting from the library's own
170
+ name, and records the set in `package.json` under `ember-addon.app-js`.
171
+ `mapFilename` renames a re-export; `exports` picks which bindings it forwards
172
+ (default `["default"]`).
173
+
174
+ Unlike the embroider plugin, nothing is written unless its content actually
175
+ differs from what is on disk — `_app_/` modules are compared byte-for-byte
176
+ and `package.json` is only rewritten when the `app-js` map itself changed —
177
+ so rebuilds don't re-trigger file watchers.
178
+
117
179
  ## Credit
118
180
 
119
181
  The `emberExternals` and `emberTransform` plugins are derived from
@@ -1,11 +1,19 @@
1
1
  {
2
2
  "name": "@nullvoxpopuli/ember-rolldown",
3
- "version": "2.0.0",
3
+ "version": "2.1.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
+ }
@@ -34,7 +34,7 @@ export function emberConfig(): TsdownPlugin {
34
34
  tsdownConfig(config: UserConfig) {
35
35
  config.sourcemap ??= true;
36
36
  config.clean ??= true;
37
- config.dts ??= true;
37
+ config.dts ??= { sourcemap: true };
38
38
  config.outExtensions ??= () => ({ js: ".js", dts: ".d.ts" });
39
39
  config.report ??= false;
40
40