ember.nvp 1.2.0 → 1.4.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.
@@ -13,6 +13,11 @@ export interface Layer {
13
13
  * deselect it).
14
14
  */
15
15
  defaultValue?: (projectType: ProjectType) => unknown;
16
+ /**
17
+ * Optional README documentation snippet or function. Whatever is returned
18
+ * will show up in the generated project's README file.
19
+ */
20
+ readme?: string | ((project: Project) => string | undefined | Promise<string | undefined>);
16
21
  /**
17
22
  * The function that applies the codemod
18
23
  *
@@ -0,0 +1,195 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
2
+ import { generate } from "#test-helpers";
3
+ import { rm } from "node:fs/promises";
4
+ import type { Project } from "ember.nvp";
5
+
6
+ describe("layer: readme", () => {
7
+ const dirs: string[] = [];
8
+
9
+ afterAll(async () => {
10
+ if (process.env.CI) return;
11
+
12
+ for (const dir of dirs) {
13
+ await rm(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
14
+ }
15
+ });
16
+
17
+ describe("App project README generation", () => {
18
+ let project: Project;
19
+
20
+ beforeAll(async () => {
21
+ project = await generate({
22
+ type: "app",
23
+ name: "my-app",
24
+ packageManager: "pnpm",
25
+ layers: ["readme", "git", "prettier", "typescript"],
26
+ });
27
+ dirs.push(project.directory);
28
+ });
29
+
30
+ it("generates README.md with project info and selected layer docs", async () => {
31
+ expect(await project.read("README.md")).toMatchInlineSnapshot(`
32
+ "# my-app
33
+
34
+ An Ember application created with \`ember.nvp\`.
35
+
36
+ ## Getting Started
37
+
38
+ ### Prerequisites
39
+
40
+ - Node.js >= 24
41
+ - pnpm
42
+
43
+ ### Installation
44
+
45
+ ~~~sh
46
+ pnpm install
47
+ ~~~
48
+
49
+ ### Development
50
+
51
+ To start the local development server:
52
+
53
+ ~~~sh
54
+ pnpm dev
55
+ ~~~
56
+
57
+ or
58
+
59
+ ~~~sh
60
+ pnpm start
61
+ ~~~
62
+
63
+ ## Features & Tooling
64
+
65
+ ### Prettier
66
+
67
+ Code formatting is managed with [Prettier](https://prettier.io/).
68
+
69
+ - \`pnpm format\` - Format code
70
+ - \`pnpm lint:prettier\` - Check code formatting
71
+
72
+ ### TypeScript
73
+
74
+ This project uses TypeScript and Glint for static type checking.
75
+
76
+ - \`pnpm lint:types\` - Typecheck code with Glint/TypeScript
77
+ "
78
+ `);
79
+ });
80
+ });
81
+
82
+ describe("Library project README generation with npm", () => {
83
+ let project: Project;
84
+
85
+ beforeAll(async () => {
86
+ project = await generate({
87
+ type: "library",
88
+ name: "my-lib",
89
+ packageManager: "npm",
90
+ layers: ["readme", "prettier"],
91
+ });
92
+ dirs.push(project.directory);
93
+ });
94
+
95
+ it("generates README.md with npm-specific commands for library", async () => {
96
+ expect(await project.read("README.md")).toMatchInlineSnapshot(`
97
+ "# my-lib
98
+
99
+ An Ember library/addon created with \`ember.nvp\`.
100
+
101
+ ## Getting Started
102
+
103
+ ### Prerequisites
104
+
105
+ - Node.js >= 24
106
+ - npm
107
+
108
+ ### Development & Building
109
+
110
+ To build the library:
111
+
112
+ ~~~sh
113
+ npm run build
114
+ ~~~
115
+
116
+ ## Features & Tooling
117
+
118
+ ### Prettier
119
+
120
+ Code formatting is managed with [Prettier](https://prettier.io/).
121
+
122
+ - \`npm run format\` - Format code
123
+ - \`npm run lint:prettier\` - Check code formatting
124
+ "
125
+ `);
126
+ });
127
+ });
128
+
129
+ describe("Extension project README generation", () => {
130
+ let project: Project;
131
+
132
+ beforeAll(async () => {
133
+ project = await generate({
134
+ type: "extension",
135
+ name: "my-extension",
136
+ packageManager: "pnpm",
137
+ layers: ["readme", "prettier"],
138
+ });
139
+ dirs.push(project.directory);
140
+ });
141
+
142
+ it("generates README.md for browser extension project", async () => {
143
+ expect(await project.read("README.md")).toMatchInlineSnapshot(`
144
+ "# my-extension
145
+
146
+ A browser extension using Ember created with \`ember.nvp\`.
147
+
148
+ ## Getting Started
149
+
150
+ ### Prerequisites
151
+
152
+ - Node.js >= 24
153
+ - pnpm
154
+
155
+ ### Development
156
+
157
+ To start development:
158
+
159
+ ~~~sh
160
+ pnpm dev
161
+ ~~~
162
+
163
+ or
164
+
165
+ ~~~sh
166
+ pnpm start
167
+ ~~~
168
+
169
+ ### Building
170
+
171
+ To build the library:
172
+
173
+ ~~~sh
174
+ pnpm build
175
+ ~~~
176
+
177
+ or
178
+
179
+ ~~~sh
180
+ pnpm build:watch
181
+ ~~~
182
+
183
+ ## Features & Tooling
184
+
185
+ ### Prettier
186
+
187
+ Code formatting is managed with [Prettier](https://prettier.io/).
188
+
189
+ - \`pnpm format\` - Format code
190
+ - \`pnpm lint:prettier\` - Check code formatting
191
+ "
192
+ `);
193
+ });
194
+ });
195
+ });
@@ -19,6 +19,7 @@ describe("discoverLayers", () => {
19
19
  "prettier",
20
20
  "publint",
21
21
  "qunit",
22
+ "readme",
22
23
  "renovate",
23
24
  "typescript",
24
25
  "vitest",
@@ -0,0 +1,352 @@
1
+ import { describe, it, beforeAll, afterAll, expect } from "vitest";
2
+ import { generate, listFiles } from "#test-helpers";
3
+ import { writeLibrarySource } from "./library-src-fixtures.ts";
4
+ import { execa } from "execa";
5
+ import { mkdir, rm, writeFile } from "node:fs/promises";
6
+ import { join } from "node:path";
7
+
8
+ import type { Project } from "ember.nvp";
9
+
10
+ /**
11
+ * A library whose package also holds dev-only code (a demo app, in-package
12
+ * tests) keeps two of each build config: a permissive `tsconfig.json` and
13
+ * `babel.config.js` that editors and `tsc --noEmit` use over everything, and
14
+ * publish-only counterparts covering just what gets published. Either publish
15
+ * config can sit in the package root or in `config/`.
16
+ *
17
+ * These tests generate a real library, put the configs in each of those
18
+ * places, and run the real `pnpm build`.
19
+ */
20
+
21
+ /**
22
+ * A development babel config: strips TypeScript, but leaves `<template>` as the
23
+ * `@ember/template-compiler` call content-tag produced. Publishing this output
24
+ * would tie the artifact to one exact ember-source.
25
+ */
26
+ const devBabelConfig = `export default {
27
+ plugins: [
28
+ ["@babel/plugin-transform-typescript", { allExtensions: true, allowDeclareFields: true }],
29
+ ],
30
+ };
31
+ `;
32
+
33
+ /**
34
+ * A publish babel config: compiles templates to \`precompileTemplate\`, which is
35
+ * what a published library ships (the consuming app does the final compile).
36
+ */
37
+ const publishBabelConfig = `export default {
38
+ plugins: [
39
+ ["@babel/plugin-transform-typescript", { allExtensions: true, allowDeclareFields: true }],
40
+ ["babel-plugin-ember-template-compilation", { targetFormat: "hbs" }],
41
+ ["module:decorator-transforms", { runtime: { import: "decorator-transforms/runtime-esm" } }],
42
+ ],
43
+ };
44
+ `;
45
+
46
+ /**
47
+ * The `tsconfig.json` such a library actually has: it covers the demo app as
48
+ * well as `src`, and deliberately has no `isolatedDeclarations` -- dev-only
49
+ * code shouldn't be held to a declaration-emit constraint. If the build reads
50
+ * this file rather than the publish tsconfig, the guard fails it, which is what
51
+ * makes every assertion below meaningful.
52
+ */
53
+ const devTsconfig = `{
54
+ "extends": "@ember/library-tsconfig",
55
+ "include": ["src", "demo-app"],
56
+ "compilerOptions": {
57
+ "lib": ["esnext", "dom", "dom.iterable"],
58
+ "types": ["ember-source/types", "@glint/ember-tsc/types"]
59
+ }
60
+ }
61
+ `;
62
+
63
+ /**
64
+ * Relative paths in a tsconfig resolve against the file itself, so the copy
65
+ * kept in `config/` has to reach back out to `src`.
66
+ */
67
+ function publishTsconfig(directory: "." | "config"): string {
68
+ const src = directory === "config" ? "../src" : "./src";
69
+
70
+ return `{
71
+ "extends": "@ember/library-tsconfig",
72
+ "include": ["${src}"],
73
+ "compilerOptions": {
74
+ "rootDir": "${src}",
75
+ "lib": ["esnext", "dom", "dom.iterable"],
76
+ "isolatedDeclarations": true,
77
+ "types": ["ember-source/types", "@glint/ember-tsc/types"]
78
+ }
79
+ }
80
+ `;
81
+ }
82
+
83
+ /** `tsconfigOption` is a literal expression: a quoted path, or `false`. */
84
+ function tsdownConfig(tsconfigOption: string): string {
85
+ return `import { defineConfig } from "tsdown";
86
+ import { ember } from "@nullvoxpopuli/ember-rolldown";
87
+
88
+ export default defineConfig({
89
+ entry: ["./src/index.ts"],
90
+ tsconfig: ${tsconfigOption},
91
+ plugins: [ember()],
92
+ });
93
+ `;
94
+ }
95
+
96
+ /** Every place a publish config can be found, so each layout starts clean. */
97
+ const BABEL_CONFIG_PATHS = ["babel.publish.config.js", "config/babel.publish.config.js"];
98
+ const TSCONFIG_PATHS = [
99
+ "tsconfig.publish.json",
100
+ "config/tsconfig.publish.json",
101
+ "config/tsconfig.json",
102
+ "tsconfig.base.json",
103
+ ];
104
+
105
+ describe("publish configs", () => {
106
+ let project: Project;
107
+
108
+ beforeAll(async () => {
109
+ project = await generate({ type: "library", name: "my-lib", layers: ["typescript"] });
110
+
111
+ await writeLibrarySource(project, "typescript");
112
+
113
+ // A library that writes its own babel config declares the plugins it uses.
114
+ const manifest = JSON.parse((await project.read("package.json"))!);
115
+
116
+ manifest.devDependencies["@babel/core"] = "^7.28.10";
117
+ manifest.devDependencies["@babel/plugin-transform-typescript"] = "^7.28.10";
118
+ manifest.devDependencies["babel-plugin-ember-template-compilation"] = "^4.0.0";
119
+
120
+ await writeFile(
121
+ join(project.directory, "package.json"),
122
+ JSON.stringify(manifest, null, 2),
123
+ "utf-8",
124
+ );
125
+
126
+ // Dev-only source: exports with no type annotations, which the publish
127
+ // tsconfig must not cover.
128
+ await mkdir(join(project.directory, "demo-app"), { recursive: true });
129
+ await writeFile(
130
+ join(project.directory, "demo-app", "app.gts"),
131
+ `export const anything = { not: "annotated" };\nexport default <template>demo</template>;\n`,
132
+ "utf-8",
133
+ );
134
+
135
+ await writeFile(join(project.directory, "tsconfig.json"), devTsconfig, "utf-8");
136
+ await writeFile(join(project.directory, "babel.config.js"), devBabelConfig, "utf-8");
137
+
138
+ const install = await execa("pnpm install", { cwd: project.directory, shell: true });
139
+ expect(install.exitCode).toBe(0);
140
+ });
141
+
142
+ afterAll(async () => {
143
+ if (process.env.CI) return;
144
+
145
+ await rm(project.directory, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
146
+ });
147
+
148
+ /** Clears every publish config, so one test's layout can't satisfy the next. */
149
+ async function resetConfigs() {
150
+ for (const relative of [...BABEL_CONFIG_PATHS, ...TSCONFIG_PATHS]) {
151
+ await rm(join(project.directory, relative), { force: true });
152
+ }
153
+
154
+ await rm(join(project.directory, "dist"), { recursive: true, force: true });
155
+ await mkdir(join(project.directory, "config"), { recursive: true });
156
+ }
157
+
158
+ async function runBuild() {
159
+ const build = await execa("pnpm build", {
160
+ cwd: project.directory,
161
+ shell: true,
162
+ reject: false,
163
+ all: true,
164
+ });
165
+
166
+ return { build, output: await project.read("dist/index.js") };
167
+ }
168
+
169
+ /** Puts the publish configs in the requested places and runs the real build. */
170
+ async function buildWith({
171
+ babel,
172
+ tsconfig,
173
+ }: {
174
+ babel: "." | "config" | false;
175
+ tsconfig: "." | "config";
176
+ }) {
177
+ await resetConfigs();
178
+
179
+ if (babel !== false) {
180
+ const relative = join(babel, "babel.publish.config.js");
181
+
182
+ await writeFile(join(project.directory, relative), publishBabelConfig, "utf-8");
183
+ }
184
+
185
+ const tsconfigRelative = join(tsconfig, "tsconfig.publish.json");
186
+
187
+ await writeFile(join(project.directory, tsconfigRelative), publishTsconfig(tsconfig), "utf-8");
188
+ await writeFile(
189
+ join(project.directory, "tsdown.config.js"),
190
+ tsdownConfig(`"./${tsconfigRelative}"`),
191
+ "utf-8",
192
+ );
193
+
194
+ return await runBuild();
195
+ }
196
+
197
+ for (const babel of [".", "config"] as const) {
198
+ for (const tsconfig of [".", "config"] as const) {
199
+ const where = (directory: string) => (directory === "." ? "the package root" : "config/");
200
+
201
+ it(`builds with the babel config in ${where(babel)} and the tsconfig in ${where(tsconfig)}`, async () => {
202
+ const { build, output } = await buildWith({ babel, tsconfig });
203
+
204
+ expect(build.exitCode, build.all).toBe(0);
205
+
206
+ // The publish babel config ran: templates came out as
207
+ // precompileTemplate, which only its template-compilation step emits.
208
+ expect(output).toContain("precompileTemplate");
209
+ expect(output).not.toContain("createTemplateFactory");
210
+
211
+ // ...and the dev config, which has no template compilation, did not.
212
+ expect(output).not.toContain("@ember/template-compiler");
213
+
214
+ // Declarations emitted, so the guard read the publish tsconfig: the
215
+ // dev tsconfig.json covering demo-app/ has no isolatedDeclarations and
216
+ // would have failed the build.
217
+ expect(await listFiles(join(project.directory, "dist"))).toContain("index.d.ts");
218
+ });
219
+ }
220
+ }
221
+
222
+ it("falls back to the dev babel config when there is no publish one", async () => {
223
+ const { build, output } = await buildWith({ babel: false, tsconfig: "." });
224
+
225
+ expect(build.exitCode, build.all).toBe(0);
226
+
227
+ // Nothing compiled the template, so content-tag's output survives -- the
228
+ // artifact you don't want to publish, which is why the preference exists.
229
+ expect(output).toContain("@ember/template-compiler");
230
+ expect(output).not.toContain("precompileTemplate");
231
+ });
232
+
233
+ /**
234
+ * Writes `files` into the project and builds with `tsconfigOption` as tsdown's
235
+ * `tsconfig`, for the cases about *which* tsconfig the guard ends up reading.
236
+ * The publish babel config is always present so these fail for tsconfig
237
+ * reasons only.
238
+ */
239
+ async function buildWithTsconfigOption(
240
+ tsconfigOption: string,
241
+ files: Record<string, string> = {},
242
+ ) {
243
+ await resetConfigs();
244
+
245
+ await writeFile(
246
+ join(project.directory, "babel.publish.config.js"),
247
+ publishBabelConfig,
248
+ "utf-8",
249
+ );
250
+
251
+ for (const [relative, contents] of Object.entries(files)) {
252
+ await writeFile(join(project.directory, relative), contents, "utf-8");
253
+ }
254
+
255
+ await writeFile(
256
+ join(project.directory, "tsdown.config.js"),
257
+ tsdownConfig(tsconfigOption),
258
+ "utf-8",
259
+ );
260
+
261
+ return await runBuild();
262
+ }
263
+
264
+ it("fails when the publish tsconfig itself lacks isolatedDeclarations", async () => {
265
+ const { build } = await buildWithTsconfigOption(`"./tsconfig.publish.json"`, {
266
+ "tsconfig.publish.json": publishTsconfig(".").replace(
267
+ `"isolatedDeclarations": true,\n `,
268
+ "",
269
+ ),
270
+ });
271
+
272
+ expect(build.exitCode).not.toBe(0);
273
+ expect(build.all).toContain('"compilerOptions.isolatedDeclarations": true');
274
+ expect(build.all).toContain("tsconfig.publish.json");
275
+ });
276
+
277
+ it("accepts a directory, reading the tsconfig.json inside it", async () => {
278
+ const { build } = await buildWithTsconfigOption(`"./config"`, {
279
+ "config/tsconfig.json": publishTsconfig("config"),
280
+ });
281
+
282
+ expect(build.exitCode, build.all).toBe(0);
283
+ });
284
+
285
+ it("takes the flag from an extends chain", async () => {
286
+ const { build } = await buildWithTsconfigOption(`"./tsconfig.publish.json"`, {
287
+ "tsconfig.base.json": `{ "compilerOptions": { "isolatedDeclarations": true } }\n`,
288
+ "tsconfig.publish.json": publishTsconfig(".").replace(
289
+ `"extends": "@ember/library-tsconfig"`,
290
+ `"extends": ["@ember/library-tsconfig", "./tsconfig.base.json"]`,
291
+ ),
292
+ });
293
+
294
+ expect(build.exitCode, build.all).toBe(0);
295
+ });
296
+
297
+ it("rejects tsconfig: false while declarations are on", async () => {
298
+ // No tsconfig means no isolatedDeclarations, so tsdown falls back to the
299
+ // tsc-based pipeline, which can't see compiled .gts and dies with
300
+ // "Source file not found". Fail with something actionable instead.
301
+ const { build } = await buildWithTsconfigOption("false");
302
+
303
+ expect(build.exitCode).not.toBe(0);
304
+ expect(build.all).toContain("`tsconfig: false` cannot be combined with declaration emit");
305
+ expect(build.all).not.toContain("Source file not found");
306
+ });
307
+
308
+ it("allows tsconfig: false for a library that ships no types", async () => {
309
+ await resetConfigs();
310
+
311
+ await writeFile(
312
+ join(project.directory, "babel.publish.config.js"),
313
+ publishBabelConfig,
314
+ "utf-8",
315
+ );
316
+ await writeFile(
317
+ join(project.directory, "tsdown.config.js"),
318
+ `import { defineConfig } from "tsdown";
319
+ import { ember } from "@nullvoxpopuli/ember-rolldown";
320
+
321
+ export default defineConfig({
322
+ entry: ["./src/index.ts"],
323
+ tsconfig: false,
324
+ dts: false,
325
+ plugins: [ember()],
326
+ });
327
+ `,
328
+ "utf-8",
329
+ );
330
+
331
+ const { build } = await runBuild();
332
+
333
+ expect(build.exitCode, build.all).toBe(0);
334
+ expect(await listFiles(join(project.directory, "dist"))).not.toContain("index.d.ts");
335
+ });
336
+
337
+ it("keeps dev-only source out of the build", async () => {
338
+ // demo-app/app.gts would be an isolated-declarations error, and builds fine
339
+ // only because the publish tsconfig doesn't cover it.
340
+ const { build } = await buildWith({ babel: ".", tsconfig: "." });
341
+
342
+ expect(build.exitCode, build.all).toBe(0);
343
+ expect(await listFiles(join(project.directory, "dist"))).toMatchInlineSnapshot(`
344
+ [
345
+ "index.d.ts",
346
+ "index.d.ts.map",
347
+ "index.js",
348
+ "index.js.map",
349
+ ]
350
+ `);
351
+ });
352
+ });