ember.nvp 1.1.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.
@@ -2,21 +2,13 @@
2
2
  "solution": {
3
3
  "ember.nvp": {
4
4
  "impact": "minor",
5
- "oldVersion": "1.0.0",
6
- "newVersion": "1.1.0",
5
+ "oldVersion": "1.1.0",
6
+ "newVersion": "1.2.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 :memo: Documentation"
16
- },
17
- {
18
- "impact": "patch",
19
- "reason": "Appears in changelog section :house: Internal"
20
12
  }
21
13
  ],
22
14
  "pkgJSONPath": "./package.json"
@@ -26,8 +18,8 @@
26
18
  },
27
19
  "@nullvoxpopuli/ember-rolldown": {
28
20
  "impact": "minor",
29
- "oldVersion": "2.0.0",
30
- "newVersion": "2.1.0",
21
+ "oldVersion": "2.1.0",
22
+ "newVersion": "2.2.0",
31
23
  "tagName": "latest",
32
24
  "constraints": [
33
25
  {
@@ -36,11 +28,7 @@
36
28
  },
37
29
  {
38
30
  "impact": "patch",
39
- "reason": "Appears in changelog section :memo: Documentation"
40
- },
41
- {
42
- "impact": "patch",
43
- "reason": "Appears in changelog section :house: Internal"
31
+ "reason": "Appears in changelog section :bug: Bug Fix"
44
32
  }
45
33
  ],
46
34
  "pkgJSONPath": "./packages/rolldown/package.json"
@@ -49,5 +37,5 @@
49
37
  "oldVersion": "1.1.0"
50
38
  }
51
39
  },
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"
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"
53
41
  }
package/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  ## Release (2026-07-22)
4
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
+
5
22
  * ember.nvp 1.1.0 (minor)
6
23
  * @nullvoxpopuli/ember-rolldown 2.1.0 (minor)
7
24
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ember.nvp",
3
- "version": "1.1.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",
@@ -99,9 +99,42 @@ because the importing component module never loads, any declaration that
99
99
  references that component dangles, surfacing as misleading
100
100
  `UNLOADABLE_DEPENDENCY` errors on `<component>.d.ts` files.
101
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.
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.
105
138
 
106
139
  ## What `ember()` does
107
140
 
@@ -128,12 +161,17 @@ ember({
128
161
  configFile: "./babel.config.js",
129
162
  babelHelpers: "bundled",
130
163
  plugins: [],
164
+ templateTransforms: [],
131
165
  filter: { include: { imports: ["ember-concurrency"], code: [] } },
132
166
  },
133
167
  });
134
168
  ```
135
169
 
136
- 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.
137
175
 
138
176
  ## App re-exports
139
177
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nullvoxpopuli/ember-rolldown",
3
- "version": "2.1.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",
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { emberBabel } from "./babel.ts";
4
+
5
+ describe("emberBabel", () => {
6
+ it("rejects templateTransforms combined with a config file", () => {
7
+ expect(() =>
8
+ emberBabel({ configFile: "./babel.config.js", templateTransforms: ["some-transform"] }),
9
+ ).toThrow(/pass the transforms/);
10
+ });
11
+
12
+ it("allows templateTransforms when config files are opted out", () => {
13
+ expect(() =>
14
+ emberBabel({ configFile: false, templateTransforms: ["some-transform"] }),
15
+ ).not.toThrow();
16
+ });
17
+ });
@@ -4,6 +4,7 @@ import templateCompilation from "babel-plugin-ember-template-compilation";
4
4
  import decoratorTransforms from "decorator-transforms";
5
5
  import { loadPartialConfigSync } from "@babel/core";
6
6
  import type { PluginItem } from "@babel/core";
7
+ import type { Transform } from "babel-plugin-ember-template-compilation";
7
8
  import type { Plugin } from "rolldown";
8
9
 
9
10
  export interface BabelOptions {
@@ -24,6 +25,15 @@ export interface BabelOptions {
24
25
  * Extra babel plugins to run, appended after the config's plugins.
25
26
  */
26
27
  plugins?: PluginItem[];
28
+ /**
29
+ * Template AST transforms (e.g. ember-scoped-css's `scopedCSS.template()`)
30
+ * passed to the default `babel-plugin-ember-template-compilation` entry.
31
+ *
32
+ * Only valid without a babel config file: with your own config, you list
33
+ * `babel-plugin-ember-template-compilation` yourself, so its `transforms`
34
+ * belong there.
35
+ */
36
+ templateTransforms?: Transform[];
27
37
  /**
28
38
  * Opt additional files into babel (see maybeBabel's `filter`). Use this to
29
39
  * cover addons that ship code needing babel (e.g. `ember-concurrency`).
@@ -63,7 +73,7 @@ function detectConfigFile(): string | undefined {
63
73
  * consuming app resolves it (the library keeps `decorator-transforms` as
64
74
  * a real dependency).
65
75
  */
66
- function defaultPlugins(): PluginItem[] {
76
+ function defaultPlugins(templateTransforms?: Transform[]): PluginItem[] {
67
77
  return [
68
78
  [
69
79
  transformTypeScript,
@@ -73,7 +83,7 @@ function defaultPlugins(): PluginItem[] {
73
83
  allowDeclareFields: true,
74
84
  },
75
85
  ],
76
- [templateCompilation, { targetFormat: "hbs" }],
86
+ [templateCompilation, { targetFormat: "hbs", transforms: templateTransforms ?? [] }],
77
87
  [
78
88
  decoratorTransforms,
79
89
  {
@@ -118,6 +128,14 @@ export function emberBabel(options: BabelOptions = {}): Plugin {
118
128
  };
119
129
 
120
130
  if (configFile) {
131
+ if (options.templateTransforms?.length) {
132
+ throw new Error(
133
+ `\`babel.templateTransforms\` cannot be combined with a babel config file (found ${configFile}). ` +
134
+ `Your config lists babel-plugin-ember-template-compilation itself, so pass the transforms ` +
135
+ `to its \`transforms\` option there instead.`,
136
+ );
137
+ }
138
+
121
139
  return maybeBabel({
122
140
  ...shared,
123
141
  configFile,
@@ -131,6 +149,6 @@ export function emberBabel(options: BabelOptions = {}): Plugin {
131
149
  // for one -- we provide the whole plugin list inline.
132
150
  configFile: false,
133
151
  babelrc: false,
134
- plugins: defaultPlugins().concat(options.plugins ?? []),
152
+ plugins: defaultPlugins(options.templateTransforms).concat(options.plugins ?? []),
135
153
  }) as unknown as Plugin;
136
154
  }
@@ -6,13 +6,30 @@ import { afterEach, describe, expect, it } from "vitest";
6
6
 
7
7
  import { ember } from "../index.ts";
8
8
 
9
+ interface Dist {
10
+ /** Sorted dist-relative paths of every emitted file. */
11
+ files: string[];
12
+ /** dist-relative path -> content, for every emitted .d.ts and .css file. */
13
+ contents: Record<string, string>;
14
+ }
15
+
16
+ let restoreCwd: (() => void) | undefined;
17
+
18
+ afterEach(() => {
19
+ restoreCwd?.();
20
+ restoreCwd = undefined;
21
+ });
22
+
9
23
  /**
10
24
  * Drives `ember()` through a real tsdown build (the dts pipeline included).
11
25
  * `files` is a map of relative path -> source, written into a temp dir that
12
26
  * becomes the cwd for the build (the externals and isolated-declarations
13
27
  * plugins read package.json / tsconfig.json from cwd, like the tsdown CLI).
28
+ *
29
+ * Returns the emitted file list plus the full contents of every declaration
30
+ * and css file (js output is covered by transform.test.ts).
14
31
  */
15
- async function buildFixture(files: Record<string, string>, entry: string[]): Promise<string> {
32
+ async function buildFixture(files: Record<string, string>, entry: string[]): Promise<Dist> {
16
33
  const dir = await mkdtemp(path.join(tmpdir(), "ember-rolldown-dts-"));
17
34
 
18
35
  const withDefaults: Record<string, string> = {
@@ -45,33 +62,40 @@ async function buildFixture(files: Record<string, string>, entry: string[]): Pro
45
62
  plugins: [ember()],
46
63
  });
47
64
 
48
- return dir;
49
- }
65
+ const distDir = path.join(dir, "dist");
66
+ const dist: Dist = { files: [], contents: {} };
50
67
 
51
- let restoreCwd: (() => void) | undefined;
68
+ for (const entry of await readdir(distDir, { withFileTypes: true, recursive: true })) {
69
+ if (!entry.isFile()) continue;
52
70
 
53
- afterEach(() => {
54
- restoreCwd?.();
55
- restoreCwd = undefined;
56
- });
71
+ const relative = path.relative(distDir, path.join(entry.parentPath, entry.name));
72
+ dist.files.push(relative);
57
73
 
58
- async function listFiles(dir: string): Promise<string[]> {
59
- const out: string[] = [];
60
- for (const entry of await readdir(dir, { withFileTypes: true, recursive: true })) {
61
- if (entry.isFile()) {
62
- out.push(path.relative(dir, path.join(entry.parentPath, entry.name)));
74
+ if (relative.endsWith(".d.ts") || relative.endsWith(".css")) {
75
+ dist.contents[relative] = await readFile(path.join(distDir, relative), "utf8");
63
76
  }
64
77
  }
65
- return out.sort();
78
+
79
+ dist.files.sort();
80
+
81
+ return dist;
82
+ }
83
+
84
+ /** One printable document of every captured file, for a single snapshot. */
85
+ function printed(dist: Dist): string {
86
+ return Object.entries(dist.contents)
87
+ .sort(([a], [b]) => a.localeCompare(b))
88
+ .map(([file, content]) => `//=== ${file}\n${content}`)
89
+ .join("\n");
66
90
  }
67
91
 
68
92
  describe("declarations (full ember() via tsdown)", () => {
69
93
  it("emits .d.ts for an entry that type-imports from a non-entry .gts", async () => {
70
- // Mirrors the luna-core shape: the component is in the runtime graph via
94
+ // Mirrors a design-system shape: the component is in the runtime graph via
71
95
  // the index entry, while a second entry (a service) only *type*-imports
72
96
  // from it. The service's declaration then references the component's
73
97
  // (virtual) .d.ts, which only exists in the bundler's module graph.
74
- const dir = await buildFixture(
98
+ const dist = await buildFixture(
75
99
  {
76
100
  "src/index.ts": `export { default as Popup } from './popup.gts';`,
77
101
  "src/service.ts": [
@@ -103,17 +127,164 @@ describe("declarations (full ember() via tsdown)", () => {
103
127
  ["./src/index.ts", "./src/service.ts"],
104
128
  );
105
129
 
106
- const dist = await listFiles(path.join(dir, "dist"));
130
+ expect(dist.files).toMatchInlineSnapshot(`
131
+ [
132
+ "index.d.ts",
133
+ "index.js",
134
+ "index.js.map",
135
+ "popup-CjMmUEVb.d.ts",
136
+ "popup-CjMmUEVb.d.ts.map",
137
+ "service.d.ts",
138
+ "service.d.ts.map",
139
+ "service.js",
140
+ "service.js.map",
141
+ ]
142
+ `);
107
143
 
108
- expect(dist).toContain("index.d.ts");
109
- expect(dist).toContain("service.d.ts");
144
+ expect(printed(dist)).toMatchInlineSnapshot(`
145
+ "//=== index.d.ts
146
+ import { t as Popup } from "./popup-CjMmUEVb.js";
147
+ export { Popup };
148
+ //=== popup-CjMmUEVb.d.ts
149
+ import Component from "@glimmer/component";
150
+ //#region src/popup.d.ts
151
+ interface PopupBlock {
152
+ open: () => void;
153
+ }
154
+ interface PopupSignature {
155
+ Element: HTMLDivElement;
156
+ Blocks: {
157
+ default: [PopupBlock];
158
+ };
159
+ }
160
+ declare class Popup extends Component<PopupSignature> {}
161
+ //#endregion
162
+ export { PopupBlock as n, Popup as t };
163
+ //# sourceMappingURL=popup-CjMmUEVb.d.ts.map
164
+ //=== service.d.ts
165
+ import { n as PopupBlock } from "./popup-CjMmUEVb.js";
166
+ //#region src/service.d.ts
167
+ declare class TooltipService {
168
+ registerPopup(context: PopupBlock): void;
169
+ }
170
+ //#endregion
171
+ export { TooltipService };
172
+ //# sourceMappingURL=service.d.ts.map"
173
+ `);
174
+ });
110
175
 
111
- const serviceDts = await readFile(path.join(dir, "dist/service.d.ts"), "utf8");
176
+ it("emits .d.ts when a .gts module is only ever type-imported", async () => {
177
+ // Nothing value-imports popup.gts, so its virtual module never enters the
178
+ // runtime graph: the only route to its declaration is the dts resolver
179
+ // loading it on demand. The declaration pipeline registers modules as
180
+ // their (virtual) source loads, so the import must resolve to the source
181
+ // id — a pre-rewritten `.d.ts` id skips that registration and dangles.
182
+ const dist = await buildFixture(
183
+ {
184
+ "src/service.ts": [
185
+ `import type { PopupBlock } from './components/popup.gts';`,
186
+ ``,
187
+ `export class TooltipService {`,
188
+ ` registerPopup(context: PopupBlock): void {`,
189
+ ` void context;`,
190
+ ` }`,
191
+ `}`,
192
+ ].join("\n"),
193
+ "src/components/popup.gts": [
194
+ `import Component from '@glimmer/component';`,
195
+ ``,
196
+ `export interface PopupBlock {`,
197
+ ` open: () => void;`,
198
+ `}`,
199
+ ``,
200
+ `export default class Popup extends Component {`,
201
+ ` <template>popup</template>`,
202
+ `}`,
203
+ ].join("\n"),
204
+ },
205
+ ["./src/service.ts"],
206
+ );
112
207
 
113
- // The PopupBlock type must be reachable: either inlined into the bundled
114
- // declaration or imported via a specifier that exists in dist.
115
- expect(serviceDts).toContain("PopupBlock");
116
- expect(serviceDts).not.toContain(".gts");
208
+ expect(dist.files).toMatchInlineSnapshot(`
209
+ [
210
+ "service.d.ts",
211
+ "service.d.ts.map",
212
+ "service.js",
213
+ "service.js.map",
214
+ ]
215
+ `);
216
+
217
+ expect(printed(dist)).toMatchInlineSnapshot(`
218
+ "//=== service.d.ts
219
+ import "@glimmer/component";
220
+ //#region src/components/popup.d.ts
221
+ interface PopupBlock {
222
+ open: () => void;
223
+ }
224
+ //#endregion
225
+ //#region src/service.d.ts
226
+ declare class TooltipService {
227
+ registerPopup(context: PopupBlock): void;
228
+ }
229
+ //#endregion
230
+ export { TooltipService };
231
+ //# sourceMappingURL=service.d.ts.map"
232
+ `);
233
+ });
234
+
235
+ it("emits .d.ts for a .gts that type-imports another (otherwise unimported) .gts", async () => {
236
+ // A nested component type-imports a sibling from the parent directory;
237
+ // its declaration then carries a relative source specifier for a module
238
+ // that exists only in the bundler's graph (never on disk, never in the
239
+ // runtime graph).
240
+ const dist = await buildFixture(
241
+ {
242
+ "src/index.ts": `export { default as OptionList } from './components/select/option-list.gts';`,
243
+ "src/components/select/option-list.gts": [
244
+ `import Component from '@glimmer/component';`,
245
+ `import type { PopupBlock } from '../popup.gts';`,
246
+ ``,
247
+ `export interface OptionListArgs {`,
248
+ ` registerPopup: (context: PopupBlock) => void;`,
249
+ `}`,
250
+ ``,
251
+ `export default class OptionList extends Component {`,
252
+ ` <template>options</template>`,
253
+ `}`,
254
+ ].join("\n"),
255
+ "src/components/popup.gts": [
256
+ `import Component from '@glimmer/component';`,
257
+ ``,
258
+ `export interface PopupBlock {`,
259
+ ` open: () => void;`,
260
+ `}`,
261
+ ``,
262
+ `export default class Popup extends Component {`,
263
+ ` <template>popup</template>`,
264
+ `}`,
265
+ ].join("\n"),
266
+ },
267
+ ["./src/index.ts"],
268
+ );
269
+
270
+ expect(dist.files).toMatchInlineSnapshot(`
271
+ [
272
+ "index.d.ts",
273
+ "index.d.ts.map",
274
+ "index.js",
275
+ "index.js.map",
276
+ ]
277
+ `);
278
+
279
+ expect(printed(dist)).toMatchInlineSnapshot(`
280
+ "//=== index.d.ts
281
+ import Component from "@glimmer/component";
282
+ //#region src/components/select/option-list.d.ts
283
+ declare class OptionList extends Component {}
284
+ //#endregion
285
+ export { OptionList };
286
+ //# sourceMappingURL=index.d.ts.map"
287
+ `);
117
288
  });
118
289
 
119
290
  it("bundles co-located CSS (via @tsdown/css) without breaking declarations", async () => {
@@ -121,8 +292,8 @@ describe("declarations (full ember() via tsdown)", () => {
121
292
  // handling the build dies on tsdown's css-guard — and because the failing
122
293
  // component module never loads, every declaration that references it
123
294
  // dangles (surfacing as misleading UNLOADABLE_DEPENDENCY errors on
124
- // <component>.d.ts). @tsdown/css (this package's peer) makes both work.
125
- const dir = await buildFixture(
295
+ // <component>.d.ts). @tsdown/css makes both work.
296
+ const dist = await buildFixture(
126
297
  {
127
298
  "src/index.ts": `export { default as Popup } from './popup.gts';`,
128
299
  "src/service.ts": [
@@ -149,18 +320,47 @@ describe("declarations (full ember() via tsdown)", () => {
149
320
  ["./src/index.ts", "./src/service.ts"],
150
321
  );
151
322
 
152
- const dist = await listFiles(path.join(dir, "dist"));
153
-
154
- const cssFiles = dist.filter((file) => file.endsWith(".css"));
155
- expect(cssFiles).toHaveLength(1);
156
-
157
- const css = await readFile(path.join(dir, "dist", cssFiles[0]!), "utf8");
158
- expect(css).toContain(".popup");
323
+ expect(dist.files).toMatchInlineSnapshot(`
324
+ [
325
+ "index.d.ts",
326
+ "index.js",
327
+ "index.js.map",
328
+ "popup-CytznrQX.d.ts",
329
+ "popup-CytznrQX.d.ts.map",
330
+ "service.d.ts",
331
+ "service.d.ts.map",
332
+ "service.js",
333
+ "service.js.map",
334
+ "style.css",
335
+ ]
336
+ `);
159
337
 
160
- // The cascade regression: the css-importing component's declaration must
161
- // still be generated and reachable from the service's declaration.
162
- const serviceDts = await readFile(path.join(dir, "dist/service.d.ts"), "utf8");
163
- expect(serviceDts).toContain("PopupBlock");
164
- expect(serviceDts).not.toContain(".gts");
338
+ expect(printed(dist)).toMatchInlineSnapshot(`
339
+ "//=== index.d.ts
340
+ import { t as Popup } from "./popup-CytznrQX.js";
341
+ export { Popup };
342
+ //=== popup-CytznrQX.d.ts
343
+ import Component from "@glimmer/component";
344
+ //#region src/popup.d.ts
345
+ interface PopupBlock {
346
+ open: () => void;
347
+ }
348
+ declare class Popup extends Component {}
349
+ //#endregion
350
+ export { PopupBlock as n, Popup as t };
351
+ //# sourceMappingURL=popup-CytznrQX.d.ts.map
352
+ //=== service.d.ts
353
+ import { n as PopupBlock } from "./popup-CytznrQX.js";
354
+ //#region src/service.d.ts
355
+ declare function register(context: PopupBlock): void;
356
+ //#endregion
357
+ export { register };
358
+ //# sourceMappingURL=service.d.ts.map
359
+ //=== style.css
360
+ .popup {
361
+ color: red;
362
+ }
363
+ "
364
+ `);
165
365
  });
166
366
  });
@@ -170,4 +170,39 @@ describe("emberTransform (full plugin via rolldown)", () => {
170
170
  "
171
171
  `);
172
172
  });
173
+
174
+ it("emits sourcemaps for the specifier rewrite (no SOURCEMAP_BROKEN warnings)", async () => {
175
+ const dir = await mkdtemp(path.join(tmpdir(), "ember-rolldown-map-"));
176
+
177
+ const files = {
178
+ // index.ts contains a `.gts` specifier, so it goes through the rewrite.
179
+ "index.ts": `export { default as Foo } from './foo.gts';`,
180
+ "foo.gts": `<template>Foo</template>`,
181
+ };
182
+
183
+ for (const [relative, source] of Object.entries(files)) {
184
+ await writeFile(path.join(dir, relative), source, "utf8");
185
+ }
186
+
187
+ const warnings: string[] = [];
188
+ const build = await rolldown({
189
+ input: path.join(dir, "index.ts"),
190
+ plugins: [emberTransform()],
191
+ external: (id) => !(id.startsWith(".") || path.isAbsolute(id)),
192
+ onwarn(warning) {
193
+ warnings.push(warning.code ?? String(warning));
194
+ },
195
+ });
196
+
197
+ const { output } = await build.generate({ format: "es", sourcemap: true });
198
+
199
+ expect(warnings.filter((code) => code === "SOURCEMAP_BROKEN")).toEqual([]);
200
+
201
+ const chunk = output.find((entry) => entry.type === "chunk" && "map" in entry);
202
+ expect(chunk).toBeDefined();
203
+ // The composed map traces through the rewrite back to the original .gts
204
+ // source (content-tag's map survives the specifier-rewrite pass).
205
+ const sources = (chunk as { map?: { sources: string[] } }).map?.sources ?? [];
206
+ expect(sources.some((source) => source.endsWith("foo.gts"))).toBe(true);
207
+ });
173
208
  });
@@ -38,13 +38,32 @@ async function fixDtsExtensionsInDir(dir: string): Promise<void> {
38
38
  }
39
39
  }
40
40
 
41
+ /**
42
+ * A sourcemap mapping every generated line to the same line of the input.
43
+ *
44
+ * `AAAA` is the [0, 0, 0, 0] VLQ segment (first line maps to source 0, line 0,
45
+ * column 0); `AACA` is [0, 0, +1, 0] (each following line advances the source
46
+ * line by one). Exact at line granularity, which is all the specifier rewrite
47
+ * can disturb.
48
+ */
49
+ function lineIdentityMap(id: string, source: string) {
50
+ return {
51
+ version: 3,
52
+ sources: [id],
53
+ sourcesContent: [source],
54
+ names: [],
55
+ mappings: source
56
+ .split("\n")
57
+ .map((_, line) => (line === 0 ? "AAAA" : "AACA"))
58
+ .join(";"),
59
+ };
60
+ }
61
+
41
62
  /**
42
63
  * Maps `.gts` -> `.ts` and `.gjs` -> `.js` (so rolldown can identify them as
43
64
  * ts/js) and preprocesses `<template>` via content-tag.
44
65
  */
45
66
  export function emberTransform(): Plugin {
46
- const virtualFiles = new Set<string>();
47
-
48
67
  return {
49
68
  name: "ember:transform",
50
69
 
@@ -55,41 +74,39 @@ export function emberTransform(): Plugin {
55
74
 
56
75
  const fileName = path.join(path.dirname(importer), id);
57
76
 
58
- if (id.endsWith(".gts") && !importer.endsWith(".d.ts")) {
59
- virtualFiles.add(fileName);
77
+ if (id.endsWith(".gts")) {
60
78
  return {
61
79
  id: fileName.replace(/\.gts$/, ".ts"),
62
80
  meta: { fileName },
63
81
  };
64
82
  }
65
83
 
66
- if (id.endsWith(".gjs") && !importer.endsWith(".d.ts")) {
67
- virtualFiles.add(fileName);
84
+ if (id.endsWith(".gjs")) {
68
85
  return {
69
86
  id: fileName.replace(/\.gjs$/, ".js"),
70
87
  meta: { fileName },
71
88
  };
72
89
  }
73
90
 
74
- if (id.endsWith(".d.ts") && importer.endsWith(".d.ts")) {
75
- const gtsFile = fileName.replace(".d.ts", ".gts");
76
- if (virtualFiles.has(gtsFile)) {
77
- return fileName;
78
- }
79
- }
80
-
81
- if (id.endsWith(".ts") && !importer.endsWith(".d.ts")) {
91
+ // A `.ts`/`.js` specifier whose only backing file is a `.gts`/`.gjs`
92
+ // resolves to the virtual module. This must also serve `.d.ts`
93
+ // importers: rolldown-plugin-dts resolves a declaration module's
94
+ // imports through the plugin pipeline (`this.resolve`), and — when the
95
+ // resolution is a source file — loads it (registering its declaration)
96
+ // before mapping the import to the declaration id. Virtual modules
97
+ // exist nowhere else, so if we don't answer here the import either
98
+ // fails to resolve (no file on disk) or, worse, resolves to a
99
+ // declaration id that was never registered.
100
+ if (id.endsWith(".ts")) {
82
101
  const gtsFileName = fileName.replace(/\.ts$/, ".gts");
83
102
  if (existsSync(gtsFileName) && !existsSync(fileName)) {
84
- virtualFiles.add(gtsFileName);
85
103
  return { id: fileName, meta: { fileName: gtsFileName } };
86
104
  }
87
105
  }
88
106
 
89
- if (id.endsWith(".js") && !importer.endsWith(".d.ts")) {
107
+ if (id.endsWith(".js")) {
90
108
  const gjsFileName = fileName.replace(/\.js$/, ".gjs");
91
109
  if (existsSync(gjsFileName) && !existsSync(fileName)) {
92
- virtualFiles.add(gjsFileName);
93
110
  return { id: fileName, meta: { fileName: gjsFileName } };
94
111
  }
95
112
  }
@@ -105,7 +122,18 @@ export function emberTransform(): Plugin {
105
122
  },
106
123
  async handler(id) {
107
124
  const meta = this.getModuleInfo(id)?.meta ?? {};
108
- const fileName = meta?.fileName;
125
+ let fileName = meta?.fileName;
126
+
127
+ // A virtual id can be loaded without having passed through our
128
+ // resolveId (rolldown-plugin-dts calls `this.load({ id })` with just
129
+ // the id), so no meta is attached: recover the backing file from disk.
130
+ if (!fileName && !existsSync(id)) {
131
+ if (id.endsWith(".ts") && existsSync(id.replace(/\.ts$/, ".gts"))) {
132
+ fileName = id.replace(/\.ts$/, ".gts");
133
+ } else if (id.endsWith(".js") && existsSync(id.replace(/\.js$/, ".gjs"))) {
134
+ fileName = id.replace(/\.js$/, ".gjs");
135
+ }
136
+ }
109
137
 
110
138
  if (fileName) {
111
139
  this.addWatchFile(fileName);
@@ -132,21 +160,26 @@ export function emberTransform(): Plugin {
132
160
  id: /\.(js|ts)$/,
133
161
  },
134
162
  handler(input, id) {
135
- if (input.includes(".gts") && id.endsWith(".d.ts")) {
136
- return input.replace(
137
- /(['"`])((?:\.\.?\/|\/|@|[A-Za-z0-9_\-])[^'"]*?\.gts)\1/g,
138
- (_m, q, p) => `${q}${p.replace(/\.gts$/, ".d.ts")}${q}`,
139
- );
140
- }
141
-
142
- if (input.includes(".gts") && !id.endsWith(".d.ts")) {
143
- return input.replace(
144
- /(['"`])((?:\.\.?\/|\/|@|[A-Za-z0-9_\-])[^'"]*?\.gts)\1/g,
145
- (_m, q, p) => `${q}${p.replace(/\.gts$/, ".ts")}${q}`,
146
- );
163
+ // Rewrite `.gts` specifiers to `.ts` — the (virtual) source id — in
164
+ // runtime modules AND declaration modules alike. Declaration modules
165
+ // must NOT be rewritten to `.d.ts`: rolldown-plugin-dts's resolver
166
+ // treats a source-file resolution as "load it (registering its
167
+ // declaration), then map to the declaration id", while a `.d.ts` id is
168
+ // returned as-is — unloadable when the module isn't registered yet
169
+ // (e.g. the `.gts` is only ever type-imported).
170
+ const output = input.replace(
171
+ /(['"`])((?:\.\.?\/|\/|@|[A-Za-z0-9_\-])[^'"]*?\.gts)\1/g,
172
+ (_m, q, p) => `${q}${p.replace(/\.gts$/, ".ts")}${q}`,
173
+ );
174
+
175
+ if (output === input) {
176
+ return null;
147
177
  }
148
178
 
149
- return input;
179
+ // The rewrite only shortens import specifiers in place, so a
180
+ // line-identity map is accurate to the line (and to the column for
181
+ // everything before the first rewritten specifier on a line).
182
+ return { code: output, map: lineIdentityMap(id, input) };
150
183
  },
151
184
  },
152
185
 
@@ -0,0 +1,211 @@
1
+ import { describe, it, beforeAll, afterAll, expect } from "vitest";
2
+ import { generate, listFiles } from "#test-helpers";
3
+ import { execa } from "execa";
4
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
5
+ import { dirname, join } from "node:path";
6
+
7
+ import type { Project } from "ember.nvp";
8
+
9
+ /**
10
+ * Integration test for building a library that uses ember-scoped-css through
11
+ * `@nullvoxpopuli/ember-rolldown` (tsdown):
12
+ *
13
+ * - the `scopedCSS()` unplugin (ember-scoped-css/rollup) resolves and rewrites
14
+ * the `?scoped=` CSS requests the template transform injects
15
+ * - the template transform + babel plugin ride along via `ember()`'s
16
+ * `babel.templateTransforms` / `babel.plugins` options
17
+ * - `@tsdown/css` with `css.inject` bundles the scoped CSS into
18
+ * `dist/style.css` and keeps the import in `dist/index.js`, so consuming
19
+ * apps load the styles through the module graph
20
+ *
21
+ * Covers all three authoring modes: co-located `.css`, inline
22
+ * `<style scoped>`, and the `scopedClass` pseudo-helper in module code.
23
+ */
24
+
25
+ const source = {
26
+ "tsdown.config.js": `import { defineConfig } from "tsdown";
27
+ import { ember } from "@nullvoxpopuli/ember-rolldown";
28
+ import { scopedCSS } from "ember-scoped-css/rollup";
29
+ import { scopedCSS as scopedCssBabel } from "ember-scoped-css/babel";
30
+
31
+ export default defineConfig({
32
+ entry: ["./src/index.ts"],
33
+ css: { inject: true },
34
+ plugins: [
35
+ ember({
36
+ babel: {
37
+ plugins: [scopedCssBabel()],
38
+ templateTransforms: [scopedCssBabel.template({})],
39
+ },
40
+ }),
41
+ scopedCSS(),
42
+ ],
43
+ });
44
+ `,
45
+ "src/index.ts": `export { Badge, type BadgeSignature } from "./components/badge.gts";
46
+ export { Banner, type BannerSignature } from "./components/banner.gts";
47
+ export { Chip, type ChipSignature, chipClass } from "./components/chip.gts";
48
+ `,
49
+ // co-located CSS
50
+ "src/components/badge.gts": `import type { TOC } from "@ember/component/template-only";
51
+
52
+ export interface BadgeSignature {
53
+ Element: HTMLSpanElement;
54
+ Blocks: {
55
+ default: [];
56
+ };
57
+ }
58
+
59
+ export const Badge: TOC<BadgeSignature> = <template>
60
+ <span class="badge" ...attributes>{{yield}}</span>
61
+ </template>;
62
+ `,
63
+ "src/components/badge.css": `.badge {
64
+ border-radius: 0.25rem;
65
+ padding: 0.25rem 0.5rem;
66
+ }
67
+ `,
68
+ // inline <style scoped>
69
+ "src/components/banner.gts": `import type { TOC } from "@ember/component/template-only";
70
+
71
+ export interface BannerSignature {
72
+ Element: HTMLDivElement;
73
+ Blocks: {
74
+ default: [];
75
+ };
76
+ }
77
+
78
+ export const Banner: TOC<BannerSignature> = <template>
79
+ <style scoped>
80
+ .banner {
81
+ border: 1px solid;
82
+ }
83
+ </style>
84
+ <div class="banner" ...attributes>{{yield}}</div>
85
+ </template>;
86
+ `,
87
+ // scopedClass pseudo-helper in module code
88
+ "src/components/chip.gts": `import type { TOC } from "@ember/component/template-only";
89
+ import { scopedClass } from "ember-scoped-css";
90
+
91
+ export const chipClass: string = scopedClass("chip");
92
+
93
+ export interface ChipSignature {
94
+ Element: HTMLSpanElement;
95
+ }
96
+
97
+ export const Chip: TOC<ChipSignature> = <template>
98
+ <span class="chip" ...attributes></span>
99
+ </template>;
100
+ `,
101
+ "src/components/chip.css": `.chip {
102
+ display: inline-flex;
103
+ }
104
+ `,
105
+ };
106
+
107
+ async function writeSource(project: Project) {
108
+ for (let [filePath, contents] of Object.entries(source)) {
109
+ let target = join(project.directory, filePath);
110
+
111
+ await mkdir(dirname(target), { recursive: true });
112
+ await writeFile(target, contents);
113
+ }
114
+ }
115
+
116
+ async function addDependencies(project: Project) {
117
+ let manifestPath = join(project.directory, "package.json");
118
+ let manifest = JSON.parse(await readFile(manifestPath, "utf8"));
119
+
120
+ // @tsdown/css releases in lockstep with tsdown; both ranges track 0.22.x
121
+ manifest.devDependencies["@tsdown/css"] = "^0.22.13";
122
+ manifest.devDependencies["ember-scoped-css"] = "^3.1.0";
123
+
124
+ await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
125
+ }
126
+
127
+ describe("library with ember-scoped-css", () => {
128
+ let project: Project;
129
+
130
+ beforeAll(async () => {
131
+ project = await generate({ type: "library", name: "scoped-lib", layers: ["typescript"] });
132
+
133
+ await writeSource(project);
134
+ await addDependencies(project);
135
+
136
+ let install = await execa("pnpm install", { cwd: project.directory, shell: true });
137
+ expect(install.exitCode).toBe(0);
138
+
139
+ let build = await execa("pnpm build", { cwd: project.directory, shell: true });
140
+ expect(build.exitCode).toBe(0);
141
+ });
142
+
143
+ afterAll(async () => {
144
+ if (process.env.CI) return;
145
+
146
+ await rm(project.directory, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
147
+ });
148
+
149
+ it("emits the expected files", async () => {
150
+ expect(await listFiles(join(project.directory, "dist"))).toMatchInlineSnapshot(`
151
+ [
152
+ "index.d.ts",
153
+ "index.d.ts.map",
154
+ "index.js",
155
+ "index.js.map",
156
+ "style.css",
157
+ ]
158
+ `);
159
+ });
160
+
161
+ it("scopes classes in the precompiled templates and keeps the CSS import", async () => {
162
+ let output = await project.read("dist/index.js");
163
+
164
+ expect(output).toMatchInlineSnapshot(`
165
+ "import './style.css';
166
+ import { precompileTemplate } from "@ember/template-compilation";
167
+ import { setComponentTemplate } from "@ember/component";
168
+ import templateOnly from "@ember/component/template-only";
169
+ //#region src/components/badge.ts
170
+ const Badge = setComponentTemplate(precompileTemplate("<span class=\\"badge_e1025e732\\" ...attributes>{{yield}}</span>", { strictMode: true }), templateOnly());
171
+ //#endregion
172
+ //#region src/components/banner.ts
173
+ const Banner = setComponentTemplate(precompileTemplate("\\n<div class=\\"banner_e88952290\\" ...attributes>{{yield}}</div>", { strictMode: true }), templateOnly());
174
+ //#endregion
175
+ //#region src/components/chip.ts
176
+ const chipClass = "chip_ec34dd962";
177
+ const Chip = setComponentTemplate(precompileTemplate("<span class=\\"chip_ec34dd962\\" ...attributes></span>", { strictMode: true }), templateOnly());
178
+ //#endregion
179
+ export { Badge, Banner, Chip, chipClass };
180
+
181
+ //# sourceMappingURL=index.js.map"
182
+ `);
183
+
184
+ // the scopedClass import is compiled away; nothing of ember-scoped-css
185
+ // remains at runtime
186
+ expect(output).not.toContain("ember-scoped-css");
187
+ });
188
+
189
+ it("rewrites and bundles all three CSS sources", async () => {
190
+ expect(await project.read("dist/style.css")).toMatchInlineSnapshot(`
191
+ ".badge_e1025e732 {
192
+ border-radius: .25rem;
193
+ padding: .25rem .5rem;
194
+ }
195
+ .banner_e88952290 {
196
+ border: 1px solid;
197
+ }
198
+ .chip_ec34dd962 {
199
+ display: inline-flex;
200
+ }
201
+ "
202
+ `);
203
+ });
204
+
205
+ it("emits declarations untouched by scoping", async () => {
206
+ let declarations = await project.read("dist/index.d.ts");
207
+
208
+ expect(declarations).toContain("declare const Badge: TOC<BadgeSignature>");
209
+ expect(declarations).not.toContain("badge_");
210
+ });
211
+ });