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
@@ -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
  }
@@ -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
 
@@ -0,0 +1,366 @@
1
+ import { mkdtemp, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import { build } from "tsdown";
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+
7
+ import { ember } from "../index.ts";
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
+
23
+ /**
24
+ * Drives `ember()` through a real tsdown build (the dts pipeline included).
25
+ * `files` is a map of relative path -> source, written into a temp dir that
26
+ * becomes the cwd for the build (the externals and isolated-declarations
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).
31
+ */
32
+ async function buildFixture(files: Record<string, string>, entry: string[]): Promise<Dist> {
33
+ const dir = await mkdtemp(path.join(tmpdir(), "ember-rolldown-dts-"));
34
+
35
+ const withDefaults: Record<string, string> = {
36
+ "package.json": JSON.stringify({
37
+ name: "fixture",
38
+ version: "0.0.0",
39
+ type: "module",
40
+ exports: { "./*": { types: "./dist/*.d.ts", default: "./dist/*.js" } },
41
+ }),
42
+ "tsconfig.json": JSON.stringify({
43
+ compilerOptions: { isolatedDeclarations: true, declaration: true },
44
+ }),
45
+ ...files,
46
+ };
47
+
48
+ for (const [relative, source] of Object.entries(withDefaults)) {
49
+ const full = path.join(dir, relative);
50
+ await mkdir(path.dirname(full), { recursive: true });
51
+ await writeFile(full, source, "utf8");
52
+ }
53
+
54
+ const previousCwd = process.cwd();
55
+ process.chdir(dir);
56
+ restoreCwd = () => process.chdir(previousCwd);
57
+
58
+ await build({
59
+ entry,
60
+ config: false,
61
+ logLevel: "silent",
62
+ plugins: [ember()],
63
+ });
64
+
65
+ const distDir = path.join(dir, "dist");
66
+ const dist: Dist = { files: [], contents: {} };
67
+
68
+ for (const entry of await readdir(distDir, { withFileTypes: true, recursive: true })) {
69
+ if (!entry.isFile()) continue;
70
+
71
+ const relative = path.relative(distDir, path.join(entry.parentPath, entry.name));
72
+ dist.files.push(relative);
73
+
74
+ if (relative.endsWith(".d.ts") || relative.endsWith(".css")) {
75
+ dist.contents[relative] = await readFile(path.join(distDir, relative), "utf8");
76
+ }
77
+ }
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");
90
+ }
91
+
92
+ describe("declarations (full ember() via tsdown)", () => {
93
+ it("emits .d.ts for an entry that type-imports from a non-entry .gts", async () => {
94
+ // Mirrors a design-system shape: the component is in the runtime graph via
95
+ // the index entry, while a second entry (a service) only *type*-imports
96
+ // from it. The service's declaration then references the component's
97
+ // (virtual) .d.ts, which only exists in the bundler's module graph.
98
+ const dist = await buildFixture(
99
+ {
100
+ "src/index.ts": `export { default as Popup } from './popup.gts';`,
101
+ "src/service.ts": [
102
+ `import type { PopupBlock } from './popup.gts';`,
103
+ ``,
104
+ `export class TooltipService {`,
105
+ ` registerPopup(context: PopupBlock): void {`,
106
+ ` void context;`,
107
+ ` }`,
108
+ `}`,
109
+ ].join("\n"),
110
+ "src/popup.gts": [
111
+ `import Component from '@glimmer/component';`,
112
+ ``,
113
+ `export interface PopupBlock {`,
114
+ ` open: () => void;`,
115
+ `}`,
116
+ ``,
117
+ `export interface PopupSignature {`,
118
+ ` Element: HTMLDivElement;`,
119
+ ` Blocks: { default: [PopupBlock] };`,
120
+ `}`,
121
+ ``,
122
+ `export default class Popup extends Component<PopupSignature> {`,
123
+ ` <template>popup</template>`,
124
+ `}`,
125
+ ].join("\n"),
126
+ },
127
+ ["./src/index.ts", "./src/service.ts"],
128
+ );
129
+
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
+ `);
143
+
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
+ });
175
+
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
+ );
207
+
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
+ `);
288
+ });
289
+
290
+ it("bundles co-located CSS (via @tsdown/css) without breaking declarations", async () => {
291
+ // Ember components routinely import a co-located .css file. Without CSS
292
+ // handling the build dies on tsdown's css-guard — and because the failing
293
+ // component module never loads, every declaration that references it
294
+ // dangles (surfacing as misleading UNLOADABLE_DEPENDENCY errors on
295
+ // <component>.d.ts). @tsdown/css makes both work.
296
+ const dist = await buildFixture(
297
+ {
298
+ "src/index.ts": `export { default as Popup } from './popup.gts';`,
299
+ "src/service.ts": [
300
+ `import type { PopupBlock } from './popup.gts';`,
301
+ ``,
302
+ `export function register(context: PopupBlock): void {`,
303
+ ` void context;`,
304
+ `}`,
305
+ ].join("\n"),
306
+ "src/popup.css": `.popup { color: red; }`,
307
+ "src/popup.gts": [
308
+ `import './popup.css';`,
309
+ `import Component from '@glimmer/component';`,
310
+ ``,
311
+ `export interface PopupBlock {`,
312
+ ` open: () => void;`,
313
+ `}`,
314
+ ``,
315
+ `export default class Popup extends Component {`,
316
+ ` <template>popup</template>`,
317
+ `}`,
318
+ ].join("\n"),
319
+ },
320
+ ["./src/index.ts", "./src/service.ts"],
321
+ );
322
+
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
+ `);
337
+
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
+ `);
365
+ });
366
+ });
@@ -0,0 +1,208 @@
1
+ import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import { rolldown } from "rolldown";
5
+ import { describe, expect, it } from "vitest";
6
+
7
+ import { emberTransform } from "./transform.ts";
8
+
9
+ /**
10
+ * Drives the plugin through a real rolldown build. `files` is a map of
11
+ * relative path -> source; the build entry is `index.ts`. Every bare (package)
12
+ * specifier is marked external so the build only exercises the local
13
+ * `.gts`/`.gjs`/`.ts` handling and doesn't need real dependencies installed.
14
+ * Returns the concatenated code of every emitted chunk.
15
+ */
16
+ async function bundle(files: Record<string, string>): Promise<string> {
17
+ const dir = await mkdtemp(path.join(tmpdir(), "ember-rolldown-build-"));
18
+
19
+ for (const [relative, source] of Object.entries(files)) {
20
+ const full = path.join(dir, relative);
21
+ await mkdir(path.dirname(full), { recursive: true });
22
+ await writeFile(full, source, "utf8");
23
+ }
24
+
25
+ const isLocal = (id: string) => id.startsWith(".") || id.startsWith("/") || path.isAbsolute(id);
26
+
27
+ const build = await rolldown({
28
+ input: path.join(dir, "index.ts"),
29
+ plugins: [emberTransform()],
30
+ external: (id) => !isLocal(id),
31
+ onwarn() {},
32
+ });
33
+
34
+ const { output } = await build.generate({ format: "es" });
35
+
36
+ const code = output
37
+ .filter((chunk): chunk is typeof chunk & { code: string } => "code" in chunk)
38
+ .map((chunk) => chunk.code)
39
+ .join("\n");
40
+
41
+ // rolldown's `//#region <path>` comments reference the random temp dir;
42
+ // collapse them to the bare filename so snapshots stay deterministic.
43
+ return code.replace(/(\/\/#region ).*\/([^/\n]+)$/gm, "$1$2");
44
+ }
45
+
46
+ describe("emberTransform (full plugin via rolldown)", () => {
47
+ it("compiles a .gts module's <template> and keeps bare imports external", async () => {
48
+ const code = await bundle({
49
+ "index.ts": `export { default as Foo } from './foo.gts';`,
50
+ "foo.gts": [
51
+ `import Component from '@glimmer/component';`,
52
+ `export default class Foo extends Component {`,
53
+ ` <template>Hello</template>`,
54
+ `}`,
55
+ ].join("\n"),
56
+ });
57
+
58
+ // <template> is gone, replaced by a content-tag `template(...)` call.
59
+ expect(code).not.toContain("<template>");
60
+ expect(code).toContain("template(");
61
+ // The bare import survived as an external dependency.
62
+ expect(code).toContain(`from "@glimmer/component"`);
63
+ // ...and it pulls in the template-compiler runtime import.
64
+ expect(code).toContain("@ember/template-compiler");
65
+
66
+ expect(code).toMatchInlineSnapshot(`
67
+ "import { template } from "@ember/template-compiler";
68
+ import Component from "@glimmer/component";
69
+ //#region foo.ts
70
+ var Foo = class extends Component {
71
+ static {
72
+ template(\`Hello\`, {
73
+ component: this,
74
+ eval() {
75
+ return eval(arguments[0]);
76
+ }
77
+ });
78
+ }
79
+ };
80
+ //#endregion
81
+ export { Foo };
82
+ "
83
+ `);
84
+ });
85
+
86
+ it("compiles .gjs alongside .gts and plain .ts in one graph", async () => {
87
+ const code = await bundle({
88
+ "index.ts": [
89
+ `export { default as Foo } from './foo.gts';`,
90
+ `export { greet } from './util.gjs';`,
91
+ `export { helper } from './plain.ts';`,
92
+ ].join("\n"),
93
+ "foo.gts": `<template>Foo</template>`,
94
+ "util.gjs": [
95
+ `export function greet() { return 'hi'; }`,
96
+ `<template>{{greet}}</template>`,
97
+ ].join("\n"),
98
+ "plain.ts": `export function helper() { return 1; }`,
99
+ });
100
+
101
+ expect(code).not.toContain("<template>");
102
+ expect(code).toContain("function greet()");
103
+ expect(code).toContain("function helper()");
104
+ expect(code).toContain("export {");
105
+
106
+ expect(code).toMatchInlineSnapshot(`
107
+ "import { template } from "@ember/template-compiler";
108
+ //#region foo.ts
109
+ var foo_default = template(\`Foo\`, { eval() {
110
+ return eval(arguments[0]);
111
+ } });
112
+ //#endregion
113
+ //#region util.js
114
+ function greet() {
115
+ return "hi";
116
+ }
117
+ var util_default = template(\`{{greet}}\`, { eval() {
118
+ return eval(arguments[0]);
119
+ } });
120
+ //#endregion
121
+ //#region plain.ts
122
+ function helper() {
123
+ return 1;
124
+ }
125
+ //#endregion
126
+ export { foo_default as Foo, greet, helper };
127
+ "
128
+ `);
129
+ });
130
+
131
+ it("resolves a .gts module that imports another .gts relatively", async () => {
132
+ const code = await bundle({
133
+ "index.ts": `export { default as Page } from './page.gts';`,
134
+ "page.gts": [
135
+ `import Widget from './widget.gts';`,
136
+ `export default class Page {`,
137
+ ` Widget = Widget;`,
138
+ ` <template><Widget /></template>`,
139
+ `}`,
140
+ ].join("\n"),
141
+ "widget.gts": `<template>widget</template>`,
142
+ });
143
+
144
+ // Both templates compiled, and the relative .gts import resolved (bundled).
145
+ expect(code).not.toContain("<template>");
146
+ expect(code).not.toContain(".gts");
147
+ expect(code).toContain("template(");
148
+
149
+ expect(code).toMatchInlineSnapshot(`
150
+ "import { template } from "@ember/template-compiler";
151
+ //#region widget.ts
152
+ var widget_default = template(\`widget\`, { eval() {
153
+ return eval(arguments[0]);
154
+ } });
155
+ //#endregion
156
+ //#region page.ts
157
+ var Page = class {
158
+ Widget = widget_default;
159
+ static {
160
+ template(\`<Widget />\`, {
161
+ component: this,
162
+ eval() {
163
+ return eval(arguments[0]);
164
+ }
165
+ });
166
+ }
167
+ };
168
+ //#endregion
169
+ export { Page };
170
+ "
171
+ `);
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
+ });
208
+ });