ember.nvp 1.9.0 → 1.10.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 (43) hide show
  1. package/.release-plan.json +22 -4
  2. package/CHANGELOG.md +20 -0
  3. package/README.md +65 -4
  4. package/package.json +2 -1
  5. package/packages/rolldown/package.json +6 -2
  6. package/packages/rolldown/src/dts.test.ts +105 -1
  7. package/packages/rolldown/src/isolated-declarations.ts +67 -61
  8. package/packages/rolldown/src/transform.test.ts +212 -15
  9. package/packages/rolldown/src/transform.ts +106 -26
  10. package/src/cli/args.js +103 -14
  11. package/src/cli/generator.js +2 -1
  12. package/src/cli/help.js +66 -16
  13. package/src/cli/index.js +15 -0
  14. package/src/cli/questions/layer-options.js +130 -0
  15. package/src/cli/questions/validate-option.js +106 -0
  16. package/src/layers/eslint-bundled/app.test.ts +182 -0
  17. package/src/layers/eslint-bundled/index.js +172 -0
  18. package/src/layers/expect-type/index.js +11 -1
  19. package/src/layers/github-actions/app.test.ts +1 -1
  20. package/src/layers/typescript/app.test.ts +6 -0
  21. package/src/layers/typescript/index.js +48 -148
  22. package/src/layers/typescript/typescript-6.js +102 -0
  23. package/src/layers/typescript/typescript-7.js +177 -0
  24. package/src/utils/manifest.js +13 -0
  25. package/src/utils/npm.js +27 -0
  26. package/src/utils/project.js +23 -0
  27. package/src/utils/types.ts +52 -1
  28. package/src/utils/typescript.js +147 -0
  29. package/test/ember.nvp/base-library.test.ts +11 -0
  30. package/test/ember.nvp/cli.test.ts +86 -1
  31. package/test/ember.nvp/layer-options.test.ts +248 -0
  32. package/test/ember.nvp/layer-readme.test.ts +4 -2
  33. package/test/ember.nvp/layer-typescript.test.ts +302 -0
  34. package/test/ember.nvp/layers.test.ts +1 -2
  35. package/test/ember.nvp/permutations/shared.ts +3 -4
  36. package/test/ember.nvp/validate-option.test.ts +152 -0
  37. package/test/test-helpers/permutate.test.ts +2 -7
  38. package/test/test-helpers.ts +8 -1
  39. package/src/layers/eslint-bundled-ember/index.js +0 -91
  40. package/src/layers/eslint-bundled-nvp/app.test.ts +0 -91
  41. package/src/layers/eslint-bundled-nvp/index.js +0 -91
  42. /package/src/layers/{eslint-bundled-ember/files → eslint-bundled/files/ember}/eslint.config.js +0 -0
  43. /package/src/layers/{eslint-bundled-nvp/files → eslint-bundled/files/nvp}/eslint.config.js +0 -0
@@ -0,0 +1,147 @@
1
+ import { packageJson } from "ember-apply";
2
+ import { join } from "node:path";
3
+ import { cp } from "node:fs/promises";
4
+ import { existsSync } from "node:fs";
5
+ import { hasConfiguredTSBabel, prependPlugin } from "#utils/babel.js";
6
+ import { getLatest } from "#utils/npm.js";
7
+
8
+ const bases = join(import.meta.dirname, "../bases");
9
+ const appBase = join(bases, "minimal-app/files");
10
+ const extensionBase = join(bases, "minimal-extension/files");
11
+ const libraryBase = join(bases, "minimal-library/files");
12
+ const customElementBase = join(bases, "minimal-custom-element/files");
13
+
14
+ /**
15
+ * TypeScript 7 has no JavaScript API.
16
+ *
17
+ * typescript-eslint imports `typescript`, so it keeps using TypeScript 6.
18
+ *
19
+ * `@typescript/typescript6` is TypeScript 6 with a `tsc6` bin,
20
+ * so `tsc` stays TypeScript 7.
21
+ */
22
+ export const TYPESCRIPT_6 = "npm:@typescript/typescript6@^6.0.2";
23
+
24
+ /**
25
+ * Content mappers (for .gts and .gjs) need 7.1 or newer.
26
+ */
27
+ export const TYPESCRIPT_7 = "npm:typescript@^7.1.0-0";
28
+
29
+ /**
30
+ * @param {Record<string, any>} manifest
31
+ * @returns {boolean}
32
+ */
33
+ export function hasTypeScript6(manifest) {
34
+ let spec = manifest.devDependencies?.typescript;
35
+
36
+ return typeof spec === "string" && spec.startsWith("npm:@typescript/typescript6@");
37
+ }
38
+
39
+ /**
40
+ * @param {import('#utils/project.js').Project} project
41
+ * @returns {boolean}
42
+ */
43
+ export function wantsTypeScript7(project) {
44
+ return project.wantsLayer("typescript") && project.getLayerOptions("typescript").version === "7";
45
+ }
46
+
47
+ /**
48
+ * Reads the manifest instead of asking the typescript layer.
49
+ * That layer's isSetup asks about eslint, and the eslint layers ask about TypeScript 7.
50
+ *
51
+ * @param {import('#utils/project.js').Project} project
52
+ * @param {Record<string, any>} manifest
53
+ * @returns {boolean}
54
+ */
55
+ export function usesTypeScript7(project, manifest) {
56
+ return wantsTypeScript7(project) || Boolean(manifest.devDependencies?.["@typescript/native"]);
57
+ }
58
+
59
+ /**
60
+ * @param {import('#utils/project.js').Project} project
61
+ * @param {Record<string, any>} manifest
62
+ * @returns {boolean}
63
+ */
64
+ export function needsTypeScript6(project, manifest) {
65
+ return project.wantsLayer("eslint") || Boolean(manifest.devDependencies?.eslint);
66
+ }
67
+
68
+ /**
69
+ * In a TypeScript 7 project:
70
+ * - keeps `@typescript/typescript6`
71
+ * - adds it when eslint needs TypeScript 6
72
+ * - removes any other `typescript`, because it would be a second `tsc`
73
+ *
74
+ * @param {import('#utils/project.js').Project} project
75
+ */
76
+ export async function syncTypeScript6(project) {
77
+ let manifest = await packageJson.read(project.directory);
78
+
79
+ if (!usesTypeScript7(project, manifest)) return;
80
+ if (hasTypeScript6(manifest)) return;
81
+
82
+ if (needsTypeScript6(project, manifest)) {
83
+ await packageJson.addDevDependencies(
84
+ await getLatest({ typescript: TYPESCRIPT_6 }),
85
+ project.directory,
86
+ );
87
+ return;
88
+ }
89
+
90
+ await packageJson.removeDevDependencies(["typescript"], project.directory);
91
+ }
92
+
93
+ /**
94
+ * Copies the base's tsconfig, unless the project already has one.
95
+ *
96
+ * @param {import('#utils/project.js').Project} project
97
+ */
98
+ export async function addTSConfig(project) {
99
+ if (existsSync(project.path("tsconfig.json"))) {
100
+ return;
101
+ }
102
+
103
+ if (project.type === "app") {
104
+ await cp(join(appBase, "tsconfig.json"), project.path("tsconfig.json"));
105
+ return;
106
+ }
107
+
108
+ if (project.type === "extension") {
109
+ await cp(join(extensionBase, "tsconfig.json"), project.path("tsconfig.json"));
110
+ return;
111
+ }
112
+
113
+ if (project.type === "library") {
114
+ await cp(join(libraryBase, "tsconfig.json"), project.path("tsconfig.json"));
115
+ return;
116
+ }
117
+
118
+ if (project.type === "custom-element") {
119
+ await cp(join(customElementBase, "tsconfig.json"), project.path("tsconfig.json"));
120
+ }
121
+ }
122
+
123
+ /**
124
+ * @param {import('#utils/project.js').Project} project
125
+ */
126
+ export async function updateBabelConfig(project) {
127
+ if (!project.hasFile("babel.config.js")) {
128
+ // Nothing to patch (libraries): ember() strips types.
129
+ return;
130
+ }
131
+
132
+ if (await hasConfiguredTSBabel(project)) {
133
+ return;
134
+ }
135
+
136
+ await prependPlugin(
137
+ project,
138
+ `[
139
+ "@babel/plugin-transform-typescript",
140
+ {
141
+ allExtensions: true,
142
+ onlyRemoveTypeImports: true,
143
+ allowDeclareFields: true,
144
+ },
145
+ ]`,
146
+ );
147
+ }
@@ -204,6 +204,17 @@ describe("base: minimal-library", () => {
204
204
  //# sourceMappingURL=index.d.ts.map"
205
205
  `);
206
206
 
207
+ // A consumer's "go to definition" opens these, from the published src/
208
+ let declarationMap = JSON.parse((await project.read("dist/index.d.ts.map"))!);
209
+
210
+ expect(declarationMap.sources).toMatchInlineSnapshot(`
211
+ [
212
+ "../src/components/badge.gts",
213
+ "../src/components/greeting.gts",
214
+ "../src/utils/math.ts",
215
+ ]
216
+ `);
217
+
207
218
  expect(await listFiles(join(project.directory, "dist"))).toMatchInlineSnapshot(`
208
219
  [
209
220
  "index.d.ts",
@@ -1,5 +1,9 @@
1
1
  import { test, expect as hardExpect } from "vitest";
2
- import { cli } from "#test-helpers";
2
+ import { cli, mktemp } from "#test-helpers";
3
+ import { packageJson } from "ember-apply";
4
+ import { existsSync } from "node:fs";
5
+ import { rm } from "node:fs/promises";
6
+ import { join } from "node:path";
3
7
  import { stripVTControlCharacters } from "node:util";
4
8
 
5
9
  const expect = hardExpect.soft;
@@ -44,6 +48,13 @@ const BASIC_HELP_OUTPUT = `
44
48
 
45
49
  --write <string>
46
50
  Confirm writing changes to disk [choices: "yes", "no"]
51
+
52
+ Layer Options:
53
+ --eslint-bundled.preset <string>
54
+ Which ESLint config? [choices: "ember", "nvp"] [default: "ember"]
55
+
56
+ --typescript.version <string>
57
+ Which TypeScript version? [choices: "7", "6"] [default: "7"]
47
58
  "
48
59
  `;
49
60
 
@@ -66,3 +77,77 @@ test("cli -h alias works", async () => {
66
77
  const outStr = stripVTControlCharacters(res.stdout);
67
78
  expect(outStr).toMatchInlineSnapshot(BASIC_HELP_OUTPUT);
68
79
  });
80
+
81
+ test("cli prints a message, not a stack trace, for an unknown flag", async () => {
82
+ let { execaPromise } = cli(["--bogus"]);
83
+
84
+ const res = await execaPromise.catch((error) => error);
85
+
86
+ expect(res.exitCode).toBe(1);
87
+ const outStr = stripVTControlCharacters(`${res.stdout}\n${res.stderr}`);
88
+ expect(outStr).toContain("Unknown option '--bogus'");
89
+ expect(outStr).not.toContain("TypeError");
90
+ });
91
+
92
+ /**
93
+ * Every question has a flag, so nothing prompts.
94
+ * The path does not exist yet, so there is no replace-or-update question either.
95
+ */
96
+ async function eslintProjectArgs(preset: string) {
97
+ let parent = await mktemp("cli-layer-options");
98
+ let path = join(parent, "my-app");
99
+
100
+ return {
101
+ parent,
102
+ path,
103
+ args: [
104
+ "--name",
105
+ "my-app",
106
+ "--path",
107
+ path,
108
+ "--type",
109
+ "app",
110
+ "--layers",
111
+ "eslint-bundled",
112
+ "--packageManager",
113
+ "pnpm",
114
+ "--confirm",
115
+ "yes",
116
+ "--eslint-bundled.preset",
117
+ preset,
118
+ ],
119
+ };
120
+ }
121
+
122
+ test("cli applies a layer option flag", async () => {
123
+ let { parent, path, args } = await eslintProjectArgs("nvp");
124
+
125
+ try {
126
+ let res = await cli(args).execaPromise;
127
+
128
+ expect(res.exitCode).toBe(0);
129
+
130
+ let manifest = await packageJson.read(path);
131
+
132
+ expect(manifest.devDependencies).toHaveProperty("@nullvoxpopuli/eslint-configs");
133
+ expect(manifest.devDependencies).not.toHaveProperty("ember-eslint");
134
+ } finally {
135
+ await rm(parent, { recursive: true, force: true });
136
+ }
137
+ });
138
+
139
+ test("cli exits on an invalid layer option flag, before writing anything", async () => {
140
+ let { parent, path, args } = await eslintProjectArgs("bogus");
141
+
142
+ try {
143
+ const res = await cli(args).execaPromise.catch((error) => error);
144
+
145
+ expect(res.exitCode).toBe(1);
146
+ expect(stripVTControlCharacters(res.stdout)).toContain(
147
+ "Invalid CLI argument '--eslint-bundled.preset': Invalid option 'bogus'. Must be one of: ember, nvp",
148
+ );
149
+ expect(existsSync(path)).toBe(false);
150
+ } finally {
151
+ await rm(parent, { recursive: true, force: true });
152
+ }
153
+ });
@@ -0,0 +1,248 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { stripVTControlCharacters } from "node:util";
3
+ import { Project } from "#utils/project.js";
4
+ import { coreOptions, parseCliArgs, parseLayerOptionsFromParsedArgs } from "#args";
5
+ import { printHelp } from "../../src/cli/help.js";
6
+ import type { DiscoveredLayer } from "#types";
7
+
8
+ describe("Layer Options Feature", () => {
9
+ const fakeKitchenSinkLayer: DiscoveredLayer = {
10
+ name: "fake-kitchen-sink",
11
+ label: "Fake Kitchen Sink Layer",
12
+ hint: "demonstrates options",
13
+ options: {
14
+ unitCount: {
15
+ type: "number",
16
+ prompt: "How many units do you want?",
17
+ default: 7,
18
+ validate: (val: number) => (val > 0 ? undefined : "Must be greater than 0"),
19
+ },
20
+ customTitle: {
21
+ type: "text",
22
+ prompt: "Enter a custom title",
23
+ default: "My Kitchen Sink",
24
+ validate: (input: string) =>
25
+ input.trim().length > 0 ? undefined : "Title cannot be empty",
26
+ },
27
+ flavor: {
28
+ type: "select",
29
+ prompt: "Which kitchen sink flavor do you prefer?",
30
+ default: "standard",
31
+ options: [
32
+ { label: "Standard", value: "standard", hint: "Regular kitchen sink setup" },
33
+ { label: "Deluxe", value: "deluxe", hint: "Includes extra features" },
34
+ ],
35
+ },
36
+ enableLogging: {
37
+ type: "confirm",
38
+ prompt: "Enable detailed sink logging?",
39
+ default: true,
40
+ },
41
+ extras: {
42
+ type: "multiselect",
43
+ prompt: "Select optional kitchen sink extras",
44
+ default: ["soap-dispenser"],
45
+ options: [
46
+ { label: "Soap Dispenser", value: "soap-dispenser", hint: "Built-in pump" },
47
+ { label: "Garbage Disposal", value: "garbage-disposal", hint: "Continuous feed" },
48
+ ],
49
+ validate: (val: string[]) => (val.length > 0 ? undefined : "Select at least one extra"),
50
+ },
51
+ },
52
+ async run(_project, _options = {}) {
53
+ // noop
54
+ },
55
+ async isSetup(_project?: Project, explain?: boolean): Promise<any> {
56
+ return explain ? { isSetup: true, reasons: [] } : true;
57
+ },
58
+ };
59
+
60
+ describe("Project.prototype.getLayerOptions", () => {
61
+ it("returns default values when no user options are provided", () => {
62
+ const project = new Project("/tmp/test", {
63
+ name: "my-app",
64
+ type: "app",
65
+ path: "/tmp/test",
66
+ packageManager: "pnpm",
67
+ layers: [fakeKitchenSinkLayer],
68
+ });
69
+
70
+ expect(project.getLayerOptions("fake-kitchen-sink")).toEqual({
71
+ unitCount: 7,
72
+ customTitle: "My Kitchen Sink",
73
+ flavor: "standard",
74
+ enableLogging: true,
75
+ extras: ["soap-dispenser"],
76
+ });
77
+ });
78
+
79
+ it("overrides default values with user-supplied options", () => {
80
+ const project = new Project("/tmp/test", {
81
+ name: "my-app",
82
+ type: "app",
83
+ path: "/tmp/test",
84
+ packageManager: "pnpm",
85
+ layers: [fakeKitchenSinkLayer],
86
+ options: {
87
+ "fake-kitchen-sink": {
88
+ unitCount: 12,
89
+ customTitle: "Custom Sink",
90
+ flavor: "deluxe",
91
+ enableLogging: false,
92
+ extras: ["garbage-disposal"],
93
+ },
94
+ },
95
+ });
96
+
97
+ expect(project.getLayerOptions("fake-kitchen-sink")).toEqual({
98
+ unitCount: 12,
99
+ customTitle: "Custom Sink",
100
+ flavor: "deluxe",
101
+ enableLogging: false,
102
+ extras: ["garbage-disposal"],
103
+ });
104
+ });
105
+
106
+ it("returns empty object for layers without options or schemas", () => {
107
+ const plainLayer: DiscoveredLayer = {
108
+ name: "plain",
109
+ label: "Plain Layer",
110
+ async run() {},
111
+ async isSetup(_project?: Project, explain?: boolean): Promise<any> {
112
+ return explain ? { isSetup: true, reasons: [] } : true;
113
+ },
114
+ };
115
+
116
+ const project = new Project("/tmp/test", {
117
+ name: "my-app",
118
+ type: "app",
119
+ path: "/tmp/test",
120
+ packageManager: "pnpm",
121
+ layers: [plainLayer],
122
+ });
123
+
124
+ expect(project.getLayerOptions("plain")).toEqual({});
125
+ });
126
+ });
127
+
128
+ describe("CLI flags", () => {
129
+ const layers = [fakeKitchenSinkLayer];
130
+
131
+ function parse(args: string[]) {
132
+ return parseLayerOptionsFromParsedArgs(layers, parseCliArgs(args, layers));
133
+ }
134
+
135
+ it("parses layer options for all option types", () => {
136
+ expect(
137
+ parse([
138
+ "--fake-kitchen-sink.unitCount=15",
139
+ "--fake-kitchen-sink.customTitle",
140
+ "CLI Title",
141
+ "--fake-kitchen-sink.flavor",
142
+ "deluxe",
143
+ "--no-fake-kitchen-sink.enableLogging",
144
+ "--fake-kitchen-sink.extras",
145
+ "soap-dispenser",
146
+ "--fake-kitchen-sink.extras",
147
+ "garbage-disposal",
148
+ ]),
149
+ ).toEqual({
150
+ "fake-kitchen-sink": {
151
+ unitCount: 15,
152
+ customTitle: "CLI Title",
153
+ flavor: "deluxe",
154
+ enableLogging: false,
155
+ extras: ["soap-dispenser", "garbage-disposal"],
156
+ },
157
+ });
158
+ });
159
+
160
+ it("turns a confirm option on without a value", () => {
161
+ expect(parse(["--fake-kitchen-sink.enableLogging"])).toEqual({
162
+ "fake-kitchen-sink": { enableLogging: true },
163
+ });
164
+ });
165
+
166
+ it("parses multiselect options supplied as comma-separated string", () => {
167
+ expect(parse(["--fake-kitchen-sink.extras", "soap-dispenser, garbage-disposal"])).toEqual({
168
+ "fake-kitchen-sink": {
169
+ extras: ["soap-dispenser", "garbage-disposal"],
170
+ },
171
+ });
172
+ });
173
+
174
+ it("leaves out options that were not passed", () => {
175
+ expect(parse(["--name", "my-app"])).toEqual({});
176
+ });
177
+
178
+ it("ignores flags for layers that were not selected", () => {
179
+ const values = parseCliArgs(["--fake-kitchen-sink.flavor", "deluxe"], layers);
180
+
181
+ expect(parseLayerOptionsFromParsedArgs([], values)).toEqual({});
182
+ });
183
+
184
+ it("throws when a confirm option is given a value", () => {
185
+ expect(() => parseCliArgs(["--fake-kitchen-sink.enableLogging=false"], layers)).toThrow(
186
+ "Option '--fake-kitchen-sink.enableLogging' does not take an argument",
187
+ );
188
+ });
189
+
190
+ it("throws on an unknown option", () => {
191
+ expect(() => parseCliArgs(["--fake-kitchen-sink.unitcount=3"], layers)).toThrow(
192
+ "Unknown option '--fake-kitchen-sink.unitcount'",
193
+ );
194
+ });
195
+
196
+ it.each([
197
+ ["a value that fails validate", "--fake-kitchen-sink.unitCount=-5"],
198
+ ["a number in hex", "--fake-kitchen-sink.unitCount=0x10"],
199
+ ["an unlisted select value", "--fake-kitchen-sink.flavor=ultra"],
200
+ ["an unlisted multiselect value", "--fake-kitchen-sink.extras=invalid-extra"],
201
+ ])("exits on %s", (_, arg) => {
202
+ const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
203
+ throw new Error("process.exit called");
204
+ }) as any);
205
+
206
+ try {
207
+ expect(() => parse([arg])).toThrow("process.exit called");
208
+ expect(exitSpy).toHaveBeenCalledWith(1);
209
+ } finally {
210
+ exitSpy.mockRestore();
211
+ }
212
+ });
213
+ });
214
+
215
+ it("lists layer options in --help", () => {
216
+ const lines: string[] = [];
217
+ const logSpy = vi.spyOn(console, "log").mockImplementation((line: string) => {
218
+ lines.push(stripVTControlCharacters(line));
219
+ });
220
+
221
+ try {
222
+ printHelp(coreOptions, [fakeKitchenSinkLayer]);
223
+ } finally {
224
+ logSpy.mockRestore();
225
+ }
226
+
227
+ const output = lines.join("\n");
228
+
229
+ expect(output.slice(output.indexOf("Layer Options:"))).toMatchInlineSnapshot(`
230
+ "Layer Options:
231
+ --fake-kitchen-sink.unitCount <number>
232
+ How many units do you want? [default: 7]
233
+
234
+ --fake-kitchen-sink.customTitle <string>
235
+ Enter a custom title [default: "My Kitchen Sink"]
236
+
237
+ --fake-kitchen-sink.flavor <string>
238
+ Which kitchen sink flavor do you prefer? [choices: "standard", "deluxe"] [default: "standard"]
239
+
240
+ --[no-]fake-kitchen-sink.enableLogging <boolean>
241
+ Enable detailed sink logging? [default: true]
242
+
243
+ --fake-kitchen-sink.extras <string>
244
+ Select optional kitchen sink extras [choices: "soap-dispenser", "garbage-disposal"] [default: ["soap-dispenser"]]
245
+ "
246
+ `);
247
+ });
248
+ });
@@ -71,9 +71,11 @@ describe("layer: readme", () => {
71
71
 
72
72
  ### TypeScript
73
73
 
74
- This project uses TypeScript and Glint for static type checking.
74
+ This project uses TypeScript 7 and [ember-content-mapper](https://github.com/NullVoxPopuli/ember-content-mapper) for static type checking.
75
75
 
76
- - \`pnpm lint:types\` - Typecheck code with Glint/TypeScript
76
+ - \`pnpm lint:types\` - Typecheck code with TypeScript
77
+ - Imports of \`.gts\` and \`.gjs\` modules must include the extension
78
+ - Editor setup: see [ember-content-mapper's editors section](https://github.com/NullVoxPopuli/ember-content-mapper#editors)
77
79
  "
78
80
  `);
79
81
  });