ember.nvp 0.7.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.
- package/.github/workflows/ci.yml +6 -0
- package/.release-plan.json +7 -59
- package/CHANGELOG.md +46 -0
- package/package.json +1 -1
- package/packages/rolldown/README.md +68 -1
- package/packages/rolldown/index.ts +8 -4
- package/packages/rolldown/package.json +13 -4
- package/packages/rolldown/rolldown.config.js +5 -3
- package/packages/rolldown/src/app-reexports.test.ts +220 -0
- package/packages/rolldown/src/app-reexports.ts +152 -0
- package/packages/rolldown/src/config.ts +50 -0
- package/packages/rolldown/src/dts.test.ts +166 -0
- package/packages/rolldown/src/transform.test.ts +173 -0
- package/src/bases/minimal-extension/extension.test.ts +251 -0
- package/src/bases/minimal-extension/files/.env.development +8 -0
- package/src/bases/minimal-extension/files/README.md +36 -0
- package/src/bases/minimal-extension/files/app/app.ts +21 -0
- package/src/bases/minimal-extension/files/app/boot.ts +8 -0
- package/src/bases/minimal-extension/files/app/config.ts +22 -0
- package/src/bases/minimal-extension/files/app/router.ts +10 -0
- package/src/bases/minimal-extension/files/app/templates/application.gts +5 -0
- package/src/bases/minimal-extension/files/babel.config.js +49 -0
- package/src/bases/minimal-extension/files/index.html +17 -0
- package/src/bases/minimal-extension/files/package.json +58 -0
- package/src/bases/minimal-extension/files/public/manifest.json +9 -0
- package/src/bases/minimal-extension/files/tsconfig.json +13 -0
- package/src/bases/minimal-extension/files/vite.config.mjs +6 -0
- package/src/bases/minimal-extension/index.js +142 -0
- package/src/bases/minimal-library/files/tsdown.config.js +2 -1
- package/src/cli/generator.js +4 -0
- package/src/cli/questions/project-type.js +9 -2
- package/src/layers/renovate/index.js +4 -3
- package/src/layers/typescript/index.js +6 -0
- package/src/utils/types.ts +1 -1
- package/test/ember.nvp/base-library.test.ts +41 -30
- package/test/ember.nvp/layer-publish-checks.test.ts +7 -10
- package/test/ember.nvp/layer-qunit.test.ts +17 -30
- package/test/ember.nvp/layer-vitest.test.ts +4 -18
- package/test/ember.nvp/permutations/app-checks.test.ts +3 -0
- package/test/ember.nvp/permutations/extension-1.test.ts +3 -0
- package/test/ember.nvp/permutations/extension-2.test.ts +3 -0
- package/test/ember.nvp/permutations/extension-checks.test.ts +3 -0
- package/test/ember.nvp/permutations/library-checks.test.ts +3 -0
- package/test/ember.nvp/permutations/shared.ts +68 -19
- package/test/test-helpers.ts +25 -5
- package/vitest.config.js +1 -1
- package/packages/rolldown/src/define-config.ts +0 -32
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { TsdownPlugin, UserConfig } from "tsdown";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sensible defaults for an Ember v2 library, applied through plugin hooks so
|
|
5
|
+
* they travel with `ember()` — you get them whether you use this package's
|
|
6
|
+
* `defineConfig`, tsdown's, or a plain `tsdown.config.js` / `rolldown.config.js`.
|
|
7
|
+
*
|
|
8
|
+
* Every value is applied with `??=`, so anything you set explicitly wins.
|
|
9
|
+
*
|
|
10
|
+
* Under tsdown, the full set applies via the `tsdownConfig` hook (analogous to
|
|
11
|
+
* Vite's `config` hook):
|
|
12
|
+
*
|
|
13
|
+
* - `sourcemap` — emit sourcemaps alongside the output.
|
|
14
|
+
* - `clean` — wipe `dist/` between builds.
|
|
15
|
+
* - `dts` — emit `.d.ts` declarations (via isolated declarations).
|
|
16
|
+
* - `outExtensions` — `.js`/`.d.ts` rather than tsdown's default `.mjs`/`.d.mts`,
|
|
17
|
+
* since exports maps conventionally point at `.js`/`.d.ts`.
|
|
18
|
+
* - `report` — off; the size report is noise for a library build.
|
|
19
|
+
* - `deps.neverBundle` — leave node builtins and the ember virtual packages to
|
|
20
|
+
* the consuming app (`emberExternals()` handles the rest).
|
|
21
|
+
*
|
|
22
|
+
* A plain rolldown build has no notion of `clean`/`dts`/`outExtensions`/`report`
|
|
23
|
+
* (those are tsdown-level concepts), so only the options with rolldown
|
|
24
|
+
* equivalents are applied there, via rolldown's own `outputOptions` hook:
|
|
25
|
+
*
|
|
26
|
+
* - `output.sourcemap` — on (output option).
|
|
27
|
+
*
|
|
28
|
+
* Externals are handled by `emberExternals()` (a `resolveId` hook) in both cases.
|
|
29
|
+
*/
|
|
30
|
+
export function emberConfig(): TsdownPlugin {
|
|
31
|
+
return {
|
|
32
|
+
name: "ember:config",
|
|
33
|
+
|
|
34
|
+
tsdownConfig(config: UserConfig) {
|
|
35
|
+
config.sourcemap ??= true;
|
|
36
|
+
config.clean ??= true;
|
|
37
|
+
config.dts ??= { sourcemap: true };
|
|
38
|
+
config.outExtensions ??= () => ({ js: ".js", dts: ".d.ts" });
|
|
39
|
+
config.report ??= false;
|
|
40
|
+
|
|
41
|
+
config.deps ??= {};
|
|
42
|
+
config.deps.neverBundle ??= ["node:*", "@ember/*", "@glimmer/*"];
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
outputOptions(options) {
|
|
46
|
+
options.sourcemap ??= true;
|
|
47
|
+
return options;
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
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
|
+
/**
|
|
10
|
+
* Drives `ember()` through a real tsdown build (the dts pipeline included).
|
|
11
|
+
* `files` is a map of relative path -> source, written into a temp dir that
|
|
12
|
+
* becomes the cwd for the build (the externals and isolated-declarations
|
|
13
|
+
* plugins read package.json / tsconfig.json from cwd, like the tsdown CLI).
|
|
14
|
+
*/
|
|
15
|
+
async function buildFixture(files: Record<string, string>, entry: string[]): Promise<string> {
|
|
16
|
+
const dir = await mkdtemp(path.join(tmpdir(), "ember-rolldown-dts-"));
|
|
17
|
+
|
|
18
|
+
const withDefaults: Record<string, string> = {
|
|
19
|
+
"package.json": JSON.stringify({
|
|
20
|
+
name: "fixture",
|
|
21
|
+
version: "0.0.0",
|
|
22
|
+
type: "module",
|
|
23
|
+
exports: { "./*": { types: "./dist/*.d.ts", default: "./dist/*.js" } },
|
|
24
|
+
}),
|
|
25
|
+
"tsconfig.json": JSON.stringify({
|
|
26
|
+
compilerOptions: { isolatedDeclarations: true, declaration: true },
|
|
27
|
+
}),
|
|
28
|
+
...files,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
for (const [relative, source] of Object.entries(withDefaults)) {
|
|
32
|
+
const full = path.join(dir, relative);
|
|
33
|
+
await mkdir(path.dirname(full), { recursive: true });
|
|
34
|
+
await writeFile(full, source, "utf8");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const previousCwd = process.cwd();
|
|
38
|
+
process.chdir(dir);
|
|
39
|
+
restoreCwd = () => process.chdir(previousCwd);
|
|
40
|
+
|
|
41
|
+
await build({
|
|
42
|
+
entry,
|
|
43
|
+
config: false,
|
|
44
|
+
logLevel: "silent",
|
|
45
|
+
plugins: [ember()],
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return dir;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let restoreCwd: (() => void) | undefined;
|
|
52
|
+
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
restoreCwd?.();
|
|
55
|
+
restoreCwd = undefined;
|
|
56
|
+
});
|
|
57
|
+
|
|
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)));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return out.sort();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe("declarations (full ember() via tsdown)", () => {
|
|
69
|
+
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
|
|
71
|
+
// the index entry, while a second entry (a service) only *type*-imports
|
|
72
|
+
// from it. The service's declaration then references the component's
|
|
73
|
+
// (virtual) .d.ts, which only exists in the bundler's module graph.
|
|
74
|
+
const dir = await buildFixture(
|
|
75
|
+
{
|
|
76
|
+
"src/index.ts": `export { default as Popup } from './popup.gts';`,
|
|
77
|
+
"src/service.ts": [
|
|
78
|
+
`import type { PopupBlock } from './popup.gts';`,
|
|
79
|
+
``,
|
|
80
|
+
`export class TooltipService {`,
|
|
81
|
+
` registerPopup(context: PopupBlock): void {`,
|
|
82
|
+
` void context;`,
|
|
83
|
+
` }`,
|
|
84
|
+
`}`,
|
|
85
|
+
].join("\n"),
|
|
86
|
+
"src/popup.gts": [
|
|
87
|
+
`import Component from '@glimmer/component';`,
|
|
88
|
+
``,
|
|
89
|
+
`export interface PopupBlock {`,
|
|
90
|
+
` open: () => void;`,
|
|
91
|
+
`}`,
|
|
92
|
+
``,
|
|
93
|
+
`export interface PopupSignature {`,
|
|
94
|
+
` Element: HTMLDivElement;`,
|
|
95
|
+
` Blocks: { default: [PopupBlock] };`,
|
|
96
|
+
`}`,
|
|
97
|
+
``,
|
|
98
|
+
`export default class Popup extends Component<PopupSignature> {`,
|
|
99
|
+
` <template>popup</template>`,
|
|
100
|
+
`}`,
|
|
101
|
+
].join("\n"),
|
|
102
|
+
},
|
|
103
|
+
["./src/index.ts", "./src/service.ts"],
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const dist = await listFiles(path.join(dir, "dist"));
|
|
107
|
+
|
|
108
|
+
expect(dist).toContain("index.d.ts");
|
|
109
|
+
expect(dist).toContain("service.d.ts");
|
|
110
|
+
|
|
111
|
+
const serviceDts = await readFile(path.join(dir, "dist/service.d.ts"), "utf8");
|
|
112
|
+
|
|
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");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("bundles co-located CSS (via @tsdown/css) without breaking declarations", async () => {
|
|
120
|
+
// Ember components routinely import a co-located .css file. Without CSS
|
|
121
|
+
// handling the build dies on tsdown's css-guard — and because the failing
|
|
122
|
+
// component module never loads, every declaration that references it
|
|
123
|
+
// 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(
|
|
126
|
+
{
|
|
127
|
+
"src/index.ts": `export { default as Popup } from './popup.gts';`,
|
|
128
|
+
"src/service.ts": [
|
|
129
|
+
`import type { PopupBlock } from './popup.gts';`,
|
|
130
|
+
``,
|
|
131
|
+
`export function register(context: PopupBlock): void {`,
|
|
132
|
+
` void context;`,
|
|
133
|
+
`}`,
|
|
134
|
+
].join("\n"),
|
|
135
|
+
"src/popup.css": `.popup { color: red; }`,
|
|
136
|
+
"src/popup.gts": [
|
|
137
|
+
`import './popup.css';`,
|
|
138
|
+
`import Component from '@glimmer/component';`,
|
|
139
|
+
``,
|
|
140
|
+
`export interface PopupBlock {`,
|
|
141
|
+
` open: () => void;`,
|
|
142
|
+
`}`,
|
|
143
|
+
``,
|
|
144
|
+
`export default class Popup extends Component {`,
|
|
145
|
+
` <template>popup</template>`,
|
|
146
|
+
`}`,
|
|
147
|
+
].join("\n"),
|
|
148
|
+
},
|
|
149
|
+
["./src/index.ts", "./src/service.ts"],
|
|
150
|
+
);
|
|
151
|
+
|
|
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");
|
|
159
|
+
|
|
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");
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
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
|
+
});
|