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
@@ -30,30 +30,73 @@ const NOT_YET_SUPPORTED: Record<ProjectType, Set<string>> = {
30
30
  app: new Set(["publint", "are-the-types-wrong"]),
31
31
  // libraries have no Application to wire inspector support into
32
32
  library: new Set(["inspector-support"]),
33
+ // publish checks: extensions aren't published (to npm)
34
+ extension: new Set(["publint", "are-the-types-wrong"]),
33
35
  };
34
36
 
37
+ const TYPE_FOR_BASE: Record<(typeof bases)[number], ProjectType> = {
38
+ "minimal-app": "app",
39
+ "minimal-library": "library",
40
+ "minimal-extension": "extension",
41
+ };
42
+
43
+ /**
44
+ * Layers that only add lint / format / publish checks. They don't affect
45
+ * the runtime shape of a project, so they permutate among themselves in a
46
+ * small "checks" matrix instead of multiplying the main one. ("apply anew"
47
+ * still applies each of them on top of every main permutation, so linear
48
+ * cross-group coverage remains.)
49
+ */
50
+ const CHECK_LAYERS = new Set<string>([
51
+ "eslint-bundled-ember",
52
+ "eslint-bundled-nvp",
53
+ "eslint-ejected",
54
+ "prettier",
55
+ "publint",
56
+ "are-the-types-wrong",
57
+ ]);
58
+
59
+ type PermutationGroup = "main" | "checks";
60
+
35
61
  const eachBase = bases.map((base) => {
36
- const type: ProjectType = base === "minimal-app" ? "app" : "library";
62
+ const type = TYPE_FOR_BASE[base]!;
37
63
 
38
64
  const applicableLayers = layers.filter((layer) => !NOT_YET_SUPPORTED[type].has(layer.name));
39
65
 
40
- const permutations = permutate(applicableLayers.map((layer) => layer.name));
41
- permutations.push([baseline]);
66
+ const toPermutations = (layerNames: string[], withBaseline: boolean) => {
67
+ const permutations = permutate(layerNames);
68
+
69
+ if (withBaseline) {
70
+ permutations.push([baseline]);
71
+ }
42
72
 
43
- const eachPermutation = permutations
44
- .filter((permutation) => {
45
- if (INITIAL_ONLY.size > 0 && !permutation.some((x) => INITIAL_ONLY.has(x))) {
46
- return;
47
- }
73
+ return permutations
74
+ .filter((permutation) => {
75
+ if (INITIAL_ONLY.size > 0 && !permutation.some((x) => INITIAL_ONLY.has(x))) {
76
+ return;
77
+ }
48
78
 
49
- // Not implemented yet
50
- if (permutation.some((x) => TODO.has(x))) {
51
- return;
52
- }
79
+ // Not implemented yet
80
+ if (permutation.some((x) => TODO.has(x))) {
81
+ return;
82
+ }
53
83
 
54
- return true;
55
- })
56
- .map((permutation) => ({ permutation, name: permutation.join(", ") }));
84
+ return true;
85
+ })
86
+ .map((permutation) => ({ permutation, name: permutation.join(", ") }));
87
+ };
88
+
89
+ const applicableNames = applicableLayers.map((layer) => layer.name);
90
+ const eachPermutation: Record<PermutationGroup, ReturnType<typeof toPermutations>> = {
91
+ main: toPermutations(
92
+ applicableNames.filter((name) => !CHECK_LAYERS.has(name)),
93
+ true,
94
+ ),
95
+ checks: toPermutations(
96
+ applicableNames.filter((name) => CHECK_LAYERS.has(name)),
97
+ false,
98
+ ),
99
+ };
57
100
 
58
101
  return { name: base, type, applicableLayers, eachPermutation };
59
102
  });
@@ -62,14 +105,20 @@ const eachBase = bases.map((base) => {
62
105
  * The permutation matrix grows exponentially with the layer count, so
63
106
  * each base runs from its own test file(s) -- CI runs them as separate,
64
107
  * parallel jobs. A base whose matrix outgrows one job's budget runs from
65
- * several files, each taking a deterministic 1-of-N slice.
108
+ * several files, each taking a deterministic 1-of-N slice. The "checks"
109
+ * group (CHECK_LAYERS) runs from its own file(s) the same way.
66
110
  *
67
111
  * @param base which base's permutations this file runs
68
- * @param slice which 1-of-N slice of the permutations this file runs
112
+ * @param options.index / options.total which 1-of-N slice of the permutations this file runs
113
+ * @param options.group which permutation group this file runs
69
114
  */
70
115
  export function testPermutations(
71
116
  base: (typeof bases)[number],
72
- slice: { index: number; total: number } = { index: 0, total: 1 },
117
+ {
118
+ index = 0,
119
+ total = 1,
120
+ group = "main",
121
+ }: { index?: number; total?: number; group?: PermutationGroup } = {},
73
122
  ) {
74
123
  const {
75
124
  type,
@@ -77,7 +126,7 @@ export function testPermutations(
77
126
  eachPermutation: allPermutations,
78
127
  } = eachBase.find((b) => b.name === base)!;
79
128
 
80
- const eachPermutation = allPermutations.filter((_, i) => i % slice.total === slice.index);
129
+ const eachPermutation = allPermutations[group].filter((_, i) => i % total === index);
81
130
 
82
131
  describe(base, () => {
83
132
  describe.each(eachPermutation)("layers: $name", ({ permutation }) => {
@@ -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
+ });
@@ -1,18 +1,19 @@
1
1
  import { execa } from "execa";
2
- import { join } from "node:path";
2
+ import { join, relative, sep } from "node:path";
3
3
  import { styleText } from "node:util";
4
4
  import { Readable, Writable } from "node:stream";
5
- import { mkdtemp, readFile, writeFile } from "node:fs/promises";
5
+ import { mkdtemp, readdir } from "node:fs/promises";
6
6
  import { tmpdir } from "node:os";
7
7
  import { generateProject, Project } from "ember.nvp";
8
8
  import { discoverLayers } from "#layers";
9
- import type { DiscoveredLayer } from "#types";
9
+ import type { DiscoveredLayer, ProjectType } from "#types";
10
10
  import { expect } from "vitest";
11
11
 
12
12
  const minimalApp = "minimal-app";
13
13
  const minimalAddon = "minimal-library";
14
+ const minimalExtension = "minimal-extension";
14
15
 
15
- export const bases = [minimalApp, minimalAddon];
16
+ export const bases = [minimalApp, minimalAddon, minimalExtension];
16
17
 
17
18
  export const layers = await discoverLayers();
18
19
 
@@ -67,31 +68,6 @@ export async function mktemp(name = "ember.nvp_test-") {
67
68
  return await mkdtemp(join(tmpdir(), `${name}-`));
68
69
  }
69
70
 
70
- /**
71
- * Temporary: `rolldown-plugin-dts` (used by tsdown for `.d.ts` generation)
72
- * imports `walk` from `yuku-parser`, but yuku-parser >= 0.6.6 moved `walk` to
73
- * `yuku-ast`. Its dependency range still floats to the broken versions, so a
74
- * fresh install of a generated library crashes when tsdown builds.
75
- *
76
- * The yuku toolchain publishes in lockstep on a shared `@yuku-toolchain/types`,
77
- * so `yuku-parser` and `yuku-codegen` must be pinned together (pinning only one
78
- * splits the toolchain and the dts/publint pipeline silently bails). Pin the
79
- * last working set before install -- here in the test harness only, so the
80
- * generated blueprint stays override-free. Remove once the plugin is fixed:
81
- * https://github.com/sxzz/rolldown-plugin-dts/issues/277
82
- */
83
- export async function pinYukuParser(project: Project): Promise<void> {
84
- const manifestPath = join(project.directory, "package.json");
85
- const manifest = JSON.parse(await readFile(manifestPath, "utf-8"));
86
-
87
- manifest.pnpm ??= {};
88
- manifest.pnpm.overrides ??= {};
89
- manifest.pnpm.overrides["yuku-parser"] = "0.6.5";
90
- manifest.pnpm.overrides["yuku-codegen"] = "0.6.5";
91
-
92
- await writeFile(manifestPath, JSON.stringify(manifest, null, 2));
93
- }
94
-
95
71
  export async function reapply(project: Project, layers: string[]) {
96
72
  return await generate({
97
73
  directory: project.directory,
@@ -112,7 +88,7 @@ export async function generate({
112
88
  directory?: string;
113
89
  layers?: string[];
114
90
  name?: string;
115
- type?: "app" | "library";
91
+ type?: ProjectType;
116
92
  packageManager?: "pnpm" | "npm";
117
93
  }): Promise<Project> {
118
94
  const tempDir = directory ?? (await mktemp(name));
@@ -211,3 +187,22 @@ export async function expectIsSetup(project: Project, layer: DiscoveredLayer) {
211
187
 
212
188
  expect(result, `${layer.name} is setup`).toBe(true);
213
189
  }
190
+
191
+ /**
192
+ * List every file under `directory` (recursively) as sorted paths relative
193
+ * to `directory`. `node_modules` is always excluded; pass
194
+ * `{ includeDist: false }` to also exclude the `dist/` build output.
195
+ */
196
+ export async function listFiles(
197
+ directory: string,
198
+ { includeDist = true }: { includeDist?: boolean } = {},
199
+ ): Promise<string[]> {
200
+ const entries = await readdir(directory, { withFileTypes: true, recursive: true });
201
+
202
+ return entries
203
+ .filter((entry) => entry.isFile())
204
+ .map((entry) => relative(directory, join(entry.parentPath, entry.name)))
205
+ .filter((path) => !path.split(sep).includes("node_modules"))
206
+ .filter((path) => includeDist || !path.startsWith(`dist${sep}`))
207
+ .sort();
208
+ }
package/vitest.config.js CHANGED
@@ -4,6 +4,6 @@ export default defineConfig({
4
4
  test: {
5
5
  testTimeout: 60_000,
6
6
  hookTimeout: 60_000,
7
- include: ["test/**/*.test.ts", "src/**/*.test.ts"],
7
+ include: ["test/**/*.test.ts", "src/**/*.test.ts", "packages/*/src/**/*.test.ts"],
8
8
  },
9
9
  });