ember.nvp 1.2.0 → 1.3.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.
@@ -2,8 +2,8 @@
2
2
  "solution": {
3
3
  "ember.nvp": {
4
4
  "impact": "minor",
5
- "oldVersion": "1.1.0",
6
- "newVersion": "1.2.0",
5
+ "oldVersion": "1.2.0",
6
+ "newVersion": "1.3.0",
7
7
  "tagName": "latest",
8
8
  "constraints": [
9
9
  {
@@ -18,17 +18,13 @@
18
18
  },
19
19
  "@nullvoxpopuli/ember-rolldown": {
20
20
  "impact": "minor",
21
- "oldVersion": "2.1.0",
22
- "newVersion": "2.2.0",
21
+ "oldVersion": "2.4.0",
22
+ "newVersion": "2.5.0",
23
23
  "tagName": "latest",
24
24
  "constraints": [
25
25
  {
26
26
  "impact": "minor",
27
27
  "reason": "Appears in changelog section :rocket: Enhancement"
28
- },
29
- {
30
- "impact": "patch",
31
- "reason": "Appears in changelog section :bug: Bug Fix"
32
28
  }
33
29
  ],
34
30
  "pkgJSONPath": "./packages/rolldown/package.json"
@@ -37,5 +33,5 @@
37
33
  "oldVersion": "1.1.0"
38
34
  }
39
35
  },
40
- "description": "## Release (2026-07-22)\n\n* ember.nvp 1.2.0 (minor)\n* @nullvoxpopuli/ember-rolldown 2.2.0 (minor)\n\n#### :rocket: Enhancement\n* `@nullvoxpopuli/ember-rolldown`, `ember.nvp`\n * [#98](https://github.com/NullVoxPopuli/ember.nvp/pull/98) Integration-test ember-scoped-css; add babel.templateTransforms ([@NullVoxPopuli-ai-agent](https://github.com/NullVoxPopuli-ai-agent))\n\n#### :bug: Bug Fix\n* `@nullvoxpopuli/ember-rolldown`\n * [#99](https://github.com/NullVoxPopuli/ember.nvp/pull/99) rolldown: fix virtual .gts declaration resolution + specifier-rewrite sourcemaps ([@NullVoxPopuli](https://github.com/NullVoxPopuli))\n\n#### Committers: 2\n- @NullVoxPopuli's reduced-access machine account for AI usage ([@NullVoxPopuli-ai-agent](https://github.com/NullVoxPopuli-ai-agent))\n- [@NullVoxPopuli](https://github.com/NullVoxPopuli)\n"
36
+ "description": "## Release (2026-08-03)\n\n* ember.nvp 1.3.0 (minor)\n* @nullvoxpopuli/ember-rolldown 2.5.0 (minor)\n\n#### :rocket: Enhancement\n* `ember.nvp`\n * [#106](https://github.com/NullVoxPopuli/ember.nvp/pull/106) README layer ([@tcjr](https://github.com/tcjr))\n* `@nullvoxpopuli/ember-rolldown`\n * [#107](https://github.com/NullVoxPopuli/ember.nvp/pull/107) rolldown: check the tsconfig the build uses, not always tsconfig.json ([@NullVoxPopuli](https://github.com/NullVoxPopuli))\n\n#### Committers: 2\n- Tom Carter ([@tcjr](https://github.com/tcjr))\n- [@NullVoxPopuli](https://github.com/NullVoxPopuli)\n"
41
37
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,47 @@
1
1
  # Changelog
2
2
 
3
+ ## Release (2026-08-03)
4
+
5
+ * ember.nvp 1.3.0 (minor)
6
+ * @nullvoxpopuli/ember-rolldown 2.5.0 (minor)
7
+
8
+ #### :rocket: Enhancement
9
+ * `ember.nvp`
10
+ * [#106](https://github.com/NullVoxPopuli/ember.nvp/pull/106) README layer ([@tcjr](https://github.com/tcjr))
11
+ * `@nullvoxpopuli/ember-rolldown`
12
+ * [#107](https://github.com/NullVoxPopuli/ember.nvp/pull/107) rolldown: check the tsconfig the build uses, not always tsconfig.json ([@NullVoxPopuli](https://github.com/NullVoxPopuli))
13
+
14
+ #### Committers: 2
15
+ - Tom Carter ([@tcjr](https://github.com/tcjr))
16
+ - [@NullVoxPopuli](https://github.com/NullVoxPopuli)
17
+
18
+ ## Release (2026-07-24)
19
+
20
+ * @nullvoxpopuli/ember-rolldown 2.4.0 (minor)
21
+
22
+ #### :rocket: Enhancement
23
+ * `@nullvoxpopuli/ember-rolldown`
24
+ * [#104](https://github.com/NullVoxPopuli/ember.nvp/pull/104) rolldown: support .gts/.gjs build entries ([@NullVoxPopuli](https://github.com/NullVoxPopuli))
25
+
26
+ #### Committers: 1
27
+ - [@NullVoxPopuli](https://github.com/NullVoxPopuli)
28
+
29
+ ## Release (2026-07-22)
30
+
31
+ * @nullvoxpopuli/ember-rolldown 2.3.0 (minor)
32
+
33
+ #### :rocket: Enhancement
34
+ * `@nullvoxpopuli/ember-rolldown`
35
+ * [#101](https://github.com/NullVoxPopuli/ember.nvp/pull/101) appReexports: accept string[] include and options as second argument ([@NullVoxPopuli-ai-agent](https://github.com/NullVoxPopuli-ai-agent))
36
+
37
+ #### :bug: Bug Fix
38
+ * `@nullvoxpopuli/ember-rolldown`
39
+ * [#103](https://github.com/NullVoxPopuli/ember.nvp/pull/103) rolldown: externalize the modules ember-source provides (renamed-modules) ([@NullVoxPopuli](https://github.com/NullVoxPopuli))
40
+
41
+ #### Committers: 2
42
+ - @NullVoxPopuli's reduced-access machine account for AI usage ([@NullVoxPopuli-ai-agent](https://github.com/NullVoxPopuli-ai-agent))
43
+ - [@NullVoxPopuli](https://github.com/NullVoxPopuli)
44
+
3
45
  ## Release (2026-07-22)
4
46
 
5
47
  * ember.nvp 1.2.0 (minor)
package/README.md CHANGED
@@ -198,6 +198,11 @@ export default {
198
198
 
199
199
  await packageJson.addScripts({ "my-script": 'echo "Hello"' }, project.directory);
200
200
  },
201
+
202
+ // Optionally add something to the README.md file
203
+ readme() {
204
+ return "### My Feature\n\nHere's what my feature brings to the table.";
205
+ },
201
206
  };
202
207
  ```
203
208
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ember.nvp",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "ember project generator, alternative to the official blueprints, but more modern -- hopefully one day to upstream back in to ember-cli / official blueprints",
5
5
  "repository": {
6
6
  "type": "git",
@@ -58,11 +58,30 @@ A `babel.config.js` is optional. Without one, `ember()` compiles templates
58
58
  [decorator-transforms](https://github.com/ef4/decorator-transforms)), and
59
59
  TypeScript; with one, your config runs instead.
60
60
 
61
+ ### Entrypoints
62
+
63
+ Entries may be any extension — `.ts`, `.js`, `.gts`, `.gjs`:
64
+
65
+ ```js
66
+ export default defineConfig({
67
+ entry: ["./src/index.ts", "./src/components/menu.gts"],
68
+ plugins: [ember()],
69
+ });
70
+ ```
71
+
72
+ Whatever the source extension, the emitted `.js` and `.d.ts` paths mirror the
73
+ entry paths (that's how tsdown's dts support works): the example emits
74
+ `dist/index.js` + `dist/index.d.ts` and `dist/components/menu.js` +
75
+ `dist/components/menu.d.ts`. Type imports resolve against those emitted
76
+ paths, so your entries — together with your `exports` map — _are_ your
77
+ public API: a module that isn't an entry has no stable `dist/` path of its
78
+ own and is only reachable through the entrypoints that re-export it.
79
+
61
80
  ### Declarations
62
81
 
63
82
  Declarations are emitted with isolated declarations — the only declaration
64
83
  pipeline that can see `<template>` (`.gts`/`.gjs`) modules, which exist only
65
- inside the bundler's module graph. Your `tsconfig.json` must enable it
84
+ inside the bundler's module graph. The tsconfig your build uses must enable it
66
85
  (`ember()` errors otherwise):
67
86
 
68
87
  ```jsonc
@@ -82,6 +101,33 @@ import type { TOC } from "@ember/component/template-only";
82
101
  export const Badge: TOC<BadgeSignature> = <template>...</template>;
83
102
  ```
84
103
 
104
+ #### Keeping the constraint off dev-only code
105
+
106
+ `isolatedDeclarations` is a constraint on how _published_ code is written, so it
107
+ should cover only the code you emit declarations for. If your package also holds
108
+ dev-only code — a demo app, in-package tests — putting the flag on the single
109
+ `tsconfig.json` that covers everything would force explicit annotations on demo
110
+ components and test helpers that never get a `.d.ts` emitted for them.
111
+
112
+ The tsconfig `ember()` checks is the one
113
+ [tsdown's `tsconfig` option](https://tsdown.dev/options/tsconfig) points at, so
114
+ point the build at a publish-only config and leave `tsconfig.json` alone for
115
+ editors and `tsc --noEmit`:
116
+
117
+ ```js
118
+ // tsdown.config.js
119
+ export default defineConfig({
120
+ entry: ["./src/index.ts"],
121
+ // include: ["src/**/*"], isolatedDeclarations: true
122
+ tsconfig: "./tsconfig.publish.json",
123
+ plugins: [ember()],
124
+ });
125
+ ```
126
+
127
+ The tradeoff: isolated-declaration errors in `src` then surface when you build
128
+ rather than in your editor, since the editor uses `tsconfig.json`. Run the build
129
+ (or `tsc --noEmit -p tsconfig.publish.json`) in CI so nothing lands unchecked.
130
+
85
131
  ## CSS
86
132
 
87
133
  Components that import co-located CSS (`import './popup.css'`) need
@@ -140,8 +186,9 @@ it.
140
186
 
141
187
  `ember()` returns an array of rolldown plugins:
142
188
 
143
- - **`emberIsolatedDeclarations()`** — errors when a `tsconfig.json` is present
144
- without `isolatedDeclarations: true`.
189
+ - **`emberIsolatedDeclarations()`** — errors when the tsconfig the build uses
190
+ (tsdown's `tsconfig` option, defaulting to `tsconfig.json`) is present without
191
+ `isolatedDeclarations: true`.
145
192
  - **`emberExternals()`** — keeps your `dependencies`, `peerDependencies`, and
146
193
  the ember virtual packages (e.g. `@ember/component`, `@glimmer/tracking`, the
147
194
  template compiler) external, so the consuming app resolves them.
@@ -194,12 +241,14 @@ export default defineConfig({
194
241
 
195
242
  With no arguments, top-level services (`services/*`) are re-exported — under
196
243
  strict mode, components and helpers are imported, but services are still
197
- injected by name. A string argument is an include glob, and an object gives
198
- full control:
244
+ injected by name. A string or array of strings is the include glob(s),
245
+ optionally followed by the remaining options; an object gives full control:
199
246
 
200
247
  ```js
201
248
  appReexports(); // services/*
202
249
  appReexports("components/**"); // one include glob
250
+ appReexports(["services/*", "helpers/*"]);
251
+ appReexports("components/**", { exclude: ["components/-private/**"] });
203
252
  appReexports({ include: ["services/*", "helpers/*"], exclude: [...] });
204
253
  ```
205
254
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nullvoxpopuli/ember-rolldown",
3
- "version": "2.2.0",
3
+ "version": "2.5.0",
4
4
  "description": "Meta plugin for building ember v2 libraries/addons with rolldown & tsdown.",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -51,13 +51,16 @@ describe("appReexports", () => {
51
51
  await rm(projectDir, { recursive: true, force: true });
52
52
  });
53
53
 
54
- async function build(options?: string | AppReexportsOptions) {
54
+ async function build(
55
+ include?: string | string[] | AppReexportsOptions,
56
+ options?: Omit<AppReexportsOptions, "include">,
57
+ ) {
55
58
  const bundle = await rolldown({
56
59
  input: {
57
60
  "components/foo": "./src/components/foo.js",
58
61
  "utils/math": "./src/utils/math.js",
59
62
  },
60
- plugins: [appReexports(options)],
63
+ plugins: [appReexports(include, options)],
61
64
  });
62
65
 
63
66
  await bundle.write({ dir: "dist" });
@@ -100,6 +103,36 @@ describe("appReexports", () => {
100
103
  expect(Object.keys(manifest["ember-addon"]["app-js"])).toEqual(["./components/foo.js"]);
101
104
  });
102
105
 
106
+ it("accepts an array of include globs", async () => {
107
+ await build(["components/**", "utils/**"]);
108
+
109
+ const manifest = await readJson("package.json");
110
+
111
+ expect(Object.keys(manifest["ember-addon"]["app-js"])).toEqual([
112
+ "./components/foo.js",
113
+ "./utils/math.js",
114
+ ]);
115
+ });
116
+
117
+ it("accepts options as the second argument alongside a string or array include", async () => {
118
+ await build("**/*.js", { exclude: ["utils/**"] });
119
+
120
+ let manifest = await readJson("package.json");
121
+
122
+ expect(Object.keys(manifest["ember-addon"]["app-js"])).toEqual(["./components/foo.js"]);
123
+
124
+ await build(["components/**", "utils/**"], {
125
+ mapFilename: (name) => name.replace("math", "arithmetic"),
126
+ });
127
+
128
+ manifest = await readJson("package.json");
129
+
130
+ expect(manifest["ember-addon"]["app-js"]).toEqual({
131
+ "./components/foo.js": "./dist/_app_/components/foo.js",
132
+ "./utils/arithmetic.js": "./dist/_app_/utils/arithmetic.js",
133
+ });
134
+ });
135
+
103
136
  it("defaults to top-level services", async () => {
104
137
  await mkdir(join(projectDir, "src/services/nested"), { recursive: true });
105
138
  await writeFile(join(projectDir, "src/services/session.js"), "export default class {}\n");
@@ -65,16 +65,27 @@ export interface AppReexportsOptions {
65
65
  * ```
66
66
  *
67
67
  * With no arguments, top-level services (`services/*`) are re-exported. A
68
- * string argument is an include glob; an object gives full control:
68
+ * string or array of strings is the include glob(s), optionally followed by
69
+ * the remaining options; an object gives full control:
69
70
  *
70
71
  * ```js
71
72
  * appReexports(); // services/*
72
73
  * appReexports("components/**"); // one include glob
74
+ * appReexports(["services/*", "helpers/*"]);
75
+ * appReexports("components/**", { exclude: ["components/-private/**"] });
73
76
  * appReexports({ include: ["services/*", "helpers/*"], exclude: [...] });
74
77
  * ```
75
78
  */
76
- export function appReexports(options: string | AppReexportsOptions = {}): Plugin {
77
- const resolved = typeof options === "string" ? { include: [options] } : options;
79
+ export function appReexports(
80
+ includeOrOptions: string | string[] | AppReexportsOptions = {},
81
+ options: Omit<AppReexportsOptions, "include"> = {},
82
+ ): Plugin {
83
+ const resolved =
84
+ typeof includeOrOptions === "string"
85
+ ? { ...options, include: [includeOrOptions] }
86
+ : Array.isArray(includeOrOptions)
87
+ ? { ...options, include: includeOrOptions }
88
+ : includeOrOptions;
78
89
  const include = resolved.include ?? ["services/*"];
79
90
 
80
91
  return {
@@ -0,0 +1,118 @@
1
+ import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import { rolldown } from "rolldown";
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+
7
+ import { emberExternals } from "./externals.ts";
8
+
9
+ let restoreCwd: (() => void) | undefined;
10
+
11
+ afterEach(() => {
12
+ restoreCwd?.();
13
+ restoreCwd = undefined;
14
+ });
15
+
16
+ /**
17
+ * An ember-source stub whose `ember-addon.renamed-modules` provides
18
+ * `@glimmer/runtime` (file-path keys, like the real manifest).
19
+ */
20
+ const EMBER_SOURCE_STUB = {
21
+ name: "ember-source",
22
+ version: "0.0.0",
23
+ "ember-addon": {
24
+ "renamed-modules": {
25
+ "@glimmer/runtime/index.js": "ember-source/@glimmer/runtime/index.js",
26
+ "@ember/-internals/glimmer/index.js": "ember-source/@ember/-internals/glimmer/index.js",
27
+ },
28
+ },
29
+ };
30
+
31
+ /**
32
+ * Writes a fixture package (manifest + optional stub ember-source in its
33
+ * node_modules) and cds into it — the plugin reads both from the library's
34
+ * own context, like the tsdown CLI.
35
+ */
36
+ async function fixture(manifest: object, { emberSource = true } = {}): Promise<string> {
37
+ const dir = await mkdtemp(path.join(tmpdir(), "ember-rolldown-externals-"));
38
+ await writeFile(path.join(dir, "package.json"), JSON.stringify(manifest));
39
+
40
+ if (emberSource) {
41
+ const stubDir = path.join(dir, "node_modules/ember-source");
42
+ await mkdir(stubDir, { recursive: true });
43
+ await writeFile(path.join(stubDir, "package.json"), JSON.stringify(EMBER_SOURCE_STUB));
44
+ }
45
+
46
+ const previousCwd = process.cwd();
47
+ process.chdir(dir);
48
+ restoreCwd = () => process.chdir(previousCwd);
49
+
50
+ return dir;
51
+ }
52
+
53
+ /** Runs the plugin's own hooks directly against the current fixture. */
54
+ function resolveWith(source: string): unknown {
55
+ const plugin = emberExternals();
56
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
57
+ (plugin.buildStart as any).call({ addWatchFile() {} });
58
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
59
+ return (plugin.resolveId as any).handler(source);
60
+ }
61
+
62
+ describe("emberExternals", () => {
63
+ it("externalizes declared dependencies and the ember virtual packages", async () => {
64
+ await fixture({ dependencies: { "some-dep": "*" } });
65
+ expect(resolveWith("some-dep")).toBe(false);
66
+ expect(resolveWith("@ember/component")).toBe(false);
67
+ expect(resolveWith("@glimmer/tracking")).toBe(false);
68
+ });
69
+
70
+ it("externalizes the modules ember-source provides via renamed-modules", async () => {
71
+ // Private API like @glimmer/runtime exists only inside the app's
72
+ // ember-source (its ember-addon.renamed-modules) — @embroider/core's
73
+ // emberVirtualPackages doesn't list it.
74
+ await fixture({});
75
+ expect(resolveWith("@glimmer/runtime")).toBe(false);
76
+ expect(resolveWith("@ember/-internals/glimmer")).toBe(false);
77
+ });
78
+
79
+ it("does not blanket-externalize @glimmer packages ember-source doesn't provide", async () => {
80
+ // e.g. @glimmer/component is a real package a library may want bundled;
81
+ // only declared deps / virtual packages / renamed-modules are external.
82
+ await fixture({});
83
+ expect(resolveWith("@glimmer/not-provided")).toBeUndefined();
84
+ });
85
+
86
+ it("leaves undeclared packages alone, with or without ember-source", async () => {
87
+ await fixture({});
88
+ expect(resolveWith("left-pad")).toBeUndefined();
89
+
90
+ await fixture({}, { emberSource: false });
91
+ expect(resolveWith("left-pad")).toBeUndefined();
92
+ // No ember-source in the graph -> no renamed-modules to consult.
93
+ expect(resolveWith("@glimmer/runtime")).toBeUndefined();
94
+ });
95
+
96
+ it("keeps a renamed-modules import external through a real build", async () => {
97
+ const dir = await fixture({ name: "build-fixture" });
98
+ await writeFile(
99
+ path.join(dir, "index.ts"),
100
+ `import { curry } from '@glimmer/runtime';\nexport const curried: unknown = curry;\n`,
101
+ );
102
+
103
+ const warnings: string[] = [];
104
+ const build = await rolldown({
105
+ input: path.join(dir, "index.ts"),
106
+ plugins: [emberExternals()],
107
+ onwarn(warning) {
108
+ warnings.push(warning.code ?? String(warning));
109
+ },
110
+ });
111
+ const { output } = await build.generate({ format: "es" });
112
+
113
+ expect(warnings.filter((code) => code === "UNRESOLVED_IMPORT")).toEqual([]);
114
+
115
+ const code = output.filter((chunk) => "code" in chunk).map((chunk) => chunk.code)[0] ?? "";
116
+ expect(code).toContain(`from "@glimmer/runtime"`);
117
+ });
118
+ });
@@ -1,4 +1,6 @@
1
1
  import { readFileSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import path from "node:path";
2
4
 
3
5
  import type { Plugin } from "rolldown";
4
6
 
@@ -33,6 +35,38 @@ function resolvableDependencies(): Set<string> {
33
35
  return deps;
34
36
  }
35
37
 
38
+ /**
39
+ * The module names ember-source provides by renaming them into itself — the
40
+ * keys of its `ember-addon.renamed-modules`, normalized from file paths to
41
+ * import specifiers (`@glimmer/runtime/index.js` → `@glimmer/runtime`).
42
+ *
43
+ * This is the authoritative list of ember's provided modules, including
44
+ * private API that @embroider/core's emberVirtualPackages doesn't cover
45
+ * (e.g. `@glimmer/runtime`). It deliberately does NOT include real packages
46
+ * like `@glimmer/component`, which a library may want bundled.
47
+ *
48
+ * Resolved from the library's own dependency graph; empty when ember-source
49
+ * isn't resolvable there.
50
+ */
51
+ function emberSourceRenamedModules(): Set<string> {
52
+ const provided = new Set<string>();
53
+
54
+ try {
55
+ const require = createRequire(path.resolve("package.json"));
56
+ const manifest = require("ember-source/package.json") as {
57
+ "ember-addon"?: { "renamed-modules"?: Record<string, string> };
58
+ };
59
+
60
+ for (const key of Object.keys(manifest["ember-addon"]?.["renamed-modules"] ?? {})) {
61
+ provided.add(key.replace(/\.js$/, "").replace(/\/index$/, ""));
62
+ }
63
+ } catch {
64
+ // The library doesn't have ember-source in its graph; nothing to provide.
65
+ }
66
+
67
+ return provided;
68
+ }
69
+
36
70
  /**
37
71
  * Keeps the library's declared dependencies and the ember virtual packages
38
72
  * (things like `@ember/component`, `@glimmer/tracking`, the template compiler,
@@ -41,6 +75,7 @@ function resolvableDependencies(): Set<string> {
41
75
  */
42
76
  export function emberExternals(): Plugin {
43
77
  let deps: Set<string>;
78
+ let renamedModules: Set<string>;
44
79
 
45
80
  return {
46
81
  name: "ember:externals",
@@ -48,6 +83,7 @@ export function emberExternals(): Plugin {
48
83
  buildStart() {
49
84
  this.addWatchFile("package.json");
50
85
  deps = resolvableDependencies();
86
+ renamedModules = emberSourceRenamedModules();
51
87
  },
52
88
 
53
89
  resolveId: {
@@ -76,6 +112,15 @@ export function emberExternals(): Plugin {
76
112
  return false;
77
113
  }
78
114
 
115
+ // Modules ember-source provides by renaming them into itself
116
+ // (`ember-addon.renamed-modules`) — private API like
117
+ // `@glimmer/runtime` that only exists inside the app's ember-source.
118
+ // Matched on the full specifier: the renamed modules are module
119
+ // paths, not packages.
120
+ if (renamedModules.has(source)) {
121
+ return false;
122
+ }
123
+
79
124
  return undefined;
80
125
  },
81
126
  },
@@ -0,0 +1,122 @@
1
+ import { mkdir, mkdtemp, 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
+ let restoreCwd: (() => void) | undefined;
10
+
11
+ afterEach(() => {
12
+ restoreCwd?.();
13
+ restoreCwd = undefined;
14
+ });
15
+
16
+ /**
17
+ * Runs a real tsdown build of a single trivial entry in a temp dir, so the
18
+ * isolated-declarations guard reads the tsconfig files written by the test the
19
+ * same way it does under the tsdown CLI. Returns the build's rejection, or
20
+ * `undefined` when it succeeded.
21
+ */
22
+ async function buildFixture(
23
+ files: Record<string, string>,
24
+ tsconfig?: string | boolean,
25
+ ): Promise<Error | undefined> {
26
+ const dir = await mkdtemp(path.join(tmpdir(), "ember-rolldown-isolated-declarations-"));
27
+
28
+ const withDefaults: Record<string, string> = {
29
+ "package.json": JSON.stringify({ name: "fixture", version: "0.0.0", type: "module" }),
30
+ "src/index.ts": `export const greeting: string = 'hello';`,
31
+ ...files,
32
+ };
33
+
34
+ for (const [relative, source] of Object.entries(withDefaults)) {
35
+ const full = path.join(dir, relative);
36
+ await mkdir(path.dirname(full), { recursive: true });
37
+ await writeFile(full, source, "utf8");
38
+ }
39
+
40
+ const previousCwd = process.cwd();
41
+ process.chdir(dir);
42
+ restoreCwd = () => process.chdir(previousCwd);
43
+
44
+ try {
45
+ await build({
46
+ entry: ["./src/index.ts"],
47
+ config: false,
48
+ logLevel: "silent",
49
+ tsconfig,
50
+ plugins: [ember()],
51
+ });
52
+
53
+ return undefined;
54
+ } catch (error) {
55
+ return error as Error;
56
+ }
57
+ }
58
+
59
+ const withFlag = JSON.stringify({
60
+ compilerOptions: { isolatedDeclarations: true, declaration: true },
61
+ });
62
+ const withoutFlag = JSON.stringify({ compilerOptions: { declaration: true } });
63
+
64
+ describe("isolated declarations guard", () => {
65
+ it("errors when the cwd tsconfig.json does not set the flag", async () => {
66
+ const error = await buildFixture({ "tsconfig.json": withoutFlag });
67
+
68
+ expect(error?.message).toContain(`must set "compilerOptions.isolatedDeclarations": true`);
69
+ });
70
+
71
+ it("passes when the cwd tsconfig.json sets the flag", async () => {
72
+ expect(await buildFixture({ "tsconfig.json": withFlag })).toBeUndefined();
73
+ });
74
+
75
+ it("checks the tsconfig the `tsconfig` option points at, not tsconfig.json", async () => {
76
+ // The shape a library with in-package dev code wants: a permissive
77
+ // tsconfig.json for editors and `tsc --noEmit` over everything, and a
78
+ // publish-only tsconfig -- the one the build uses -- carrying the flag.
79
+ const error = await buildFixture(
80
+ { "tsconfig.json": withoutFlag, "tsconfig.publish.json": withFlag },
81
+ "./tsconfig.publish.json",
82
+ );
83
+
84
+ expect(error).toBeUndefined();
85
+ });
86
+
87
+ it("errors when the tsconfig the `tsconfig` option points at lacks the flag", async () => {
88
+ const error = await buildFixture(
89
+ { "tsconfig.json": withFlag, "tsconfig.publish.json": withoutFlag },
90
+ "./tsconfig.publish.json",
91
+ );
92
+
93
+ expect(error?.message).toContain("tsconfig.publish.json");
94
+ expect(error?.message).toContain(`must set "compilerOptions.isolatedDeclarations": true`);
95
+ });
96
+
97
+ it("resolves a directory `tsconfig` option to the tsconfig.json inside it", async () => {
98
+ const error = await buildFixture(
99
+ { "tsconfig.json": withFlag, "config/tsconfig.json": withoutFlag },
100
+ "./config",
101
+ );
102
+
103
+ expect(error?.message).toContain(`must set "compilerOptions.isolatedDeclarations": true`);
104
+ });
105
+
106
+ it("skips the check when there is no tsconfig at all", async () => {
107
+ expect(await buildFixture({})).toBeUndefined();
108
+ });
109
+
110
+ it("skips the check when tsconfig is disabled", async () => {
111
+ expect(await buildFixture({ "tsconfig.json": withoutFlag }, false)).toBeUndefined();
112
+ });
113
+
114
+ it("inherits the flag through an extends chain", async () => {
115
+ const error = await buildFixture({
116
+ "tsconfig.base.json": withFlag,
117
+ "tsconfig.json": JSON.stringify({ extends: "./tsconfig.base.json" }),
118
+ });
119
+
120
+ expect(error).toBeUndefined();
121
+ });
122
+ });
@@ -1,10 +1,39 @@
1
- import { existsSync } from "node:fs";
1
+ import { existsSync, statSync } from "node:fs";
2
2
  import { dirname, resolve } from "node:path";
3
3
 
4
4
  import type { Plugin } from "rolldown";
5
+ import type { UserConfig } from "tsdown";
5
6
 
6
7
  /**
7
- * Errors the build when a `tsconfig.json` is present without
8
+ * Resolve the tsconfig the build will actually use, mirroring tsdown's own
9
+ * `tsconfig` option (`string | boolean`):
10
+ *
11
+ * - `undefined` / `true`: `tsconfig.json` in the cwd.
12
+ * - a path to a file: that file.
13
+ * - a path to a directory: `tsconfig.json` inside it.
14
+ * - `false`: no tsconfig at all.
15
+ *
16
+ * Under plain rolldown there is no such option, so the cwd's `tsconfig.json`
17
+ * is all there is.
18
+ */
19
+ function resolveTsconfigPath(option: string | boolean | undefined): string | undefined {
20
+ if (option === false) return undefined;
21
+
22
+ if (typeof option === "string") {
23
+ const asPath = resolve(option);
24
+
25
+ if (existsSync(asPath) && statSync(asPath).isDirectory()) {
26
+ return resolve(asPath, "tsconfig.json");
27
+ }
28
+
29
+ return asPath;
30
+ }
31
+
32
+ return resolve("tsconfig.json");
33
+ }
34
+
35
+ /**
36
+ * Errors the build when the tsconfig it builds with does not set
8
37
  * `isolatedDeclarations: true`.
9
38
  *
10
39
  * This is required: declarations for `.gts`/`.gjs` (template tag) modules can
@@ -18,18 +47,34 @@ import type { Plugin } from "rolldown";
18
47
  * same rules the build enforces (exported values need explicit type
19
48
  * annotations, e.g. `export const X: TOC<Sig> = <template>...`).
20
49
  *
21
- * Projects without a `tsconfig.json` (JavaScript libraries) or without the
22
- * `typescript` package have no declarations to emit, so there is nothing to
23
- * check.
50
+ * The tsconfig checked is the one tsdown's `tsconfig` option points at, not
51
+ * necessarily `tsconfig.json`. A library whose package also holds dev-only code
52
+ * (a demo app, in-package tests) can therefore keep a permissive
53
+ * `tsconfig.json` covering everything for editors and `tsc --noEmit`, and point
54
+ * the build at a `tsconfig.publish.json` that covers only the publishable
55
+ * sources -- isolated declarations then constrain exactly the code that gets
56
+ * declarations emitted for it, and nothing else.
57
+ *
58
+ * Projects with no tsconfig (JavaScript libraries, or `tsconfig: false`) or
59
+ * without the `typescript` package have no declarations to emit, so there is
60
+ * nothing to check.
24
61
  */
25
62
  export function emberIsolatedDeclarations(): Plugin {
63
+ let tsconfigOption: string | boolean | undefined;
64
+
26
65
  return {
27
66
  name: "ember:isolated-declarations",
28
67
 
68
+ // Not called under plain rolldown, which leaves `tsconfigOption` undefined
69
+ // -- i.e. the cwd's tsconfig.json, the only thing it could mean there.
70
+ tsdownConfig(config: UserConfig) {
71
+ tsconfigOption = config.tsconfig;
72
+ },
73
+
29
74
  async buildStart() {
30
- const tsconfigPath = resolve("tsconfig.json");
75
+ const tsconfigPath = resolveTsconfigPath(tsconfigOption);
31
76
 
32
- if (!existsSync(tsconfigPath)) return;
77
+ if (!tsconfigPath || !existsSync(tsconfigPath)) return;
33
78
 
34
79
  let ts: typeof import("typescript");
35
80
 
@@ -57,7 +102,10 @@ export function emberIsolatedDeclarations(): Plugin {
57
102
  `isolated-declarations pipeline, which reads compiled modules from the bundler's ` +
58
103
  `module graph -- the tsc-based pipeline reads from disk and cannot see them.\n\n` +
59
104
  `Isolated declarations require exported values to have explicit type annotations, ` +
60
- `e.g. \`export const X: TOC<Sig> = <template>...\`.`,
105
+ `e.g. \`export const X: TOC<Sig> = <template>...\`.\n\n` +
106
+ `If this tsconfig also covers dev-only code (a demo app, in-package tests) that ` +
107
+ `should not be constrained this way, point tsdown's \`tsconfig\` option at a ` +
108
+ `publish-only tsconfig that sets the flag and covers just the published sources.`,
61
109
  );
62
110
  }
63
111
  },
@@ -13,7 +13,10 @@ import { emberTransform } from "./transform.ts";
13
13
  * `.gts`/`.gjs`/`.ts` handling and doesn't need real dependencies installed.
14
14
  * Returns the concatenated code of every emitted chunk.
15
15
  */
16
- async function bundle(files: Record<string, string>): Promise<string> {
16
+ async function bundle(
17
+ files: Record<string, string>,
18
+ { input = ["index.ts"] }: { input?: string[] } = {},
19
+ ): Promise<string> {
17
20
  const dir = await mkdtemp(path.join(tmpdir(), "ember-rolldown-build-"));
18
21
 
19
22
  for (const [relative, source] of Object.entries(files)) {
@@ -25,7 +28,7 @@ async function bundle(files: Record<string, string>): Promise<string> {
25
28
  const isLocal = (id: string) => id.startsWith(".") || id.startsWith("/") || path.isAbsolute(id);
26
29
 
27
30
  const build = await rolldown({
28
- input: path.join(dir, "index.ts"),
31
+ input: input.map((relative) => path.join(dir, relative)),
29
32
  plugins: [emberTransform()],
30
33
  external: (id) => !isLocal(id),
31
34
  onwarn() {},
@@ -171,6 +174,54 @@ describe("emberTransform (full plugin via rolldown)", () => {
171
174
  `);
172
175
  });
173
176
 
177
+ it("compiles a .gts module used directly as an entry (no importer)", async () => {
178
+ const code = await bundle(
179
+ {
180
+ // Both an entry .gts and a .ts entry importing it: the shared module
181
+ // must resolve to ONE id (the entry resolution realpaths, matching
182
+ // rolldown's own resolver) so it lands in the entry chunk and the
183
+ // index chunk imports it, rather than duplicating the code.
184
+ "index.ts": `export { default as Foo } from './foo.gts';`,
185
+ "foo.gts": [
186
+ `import Component from '@glimmer/component';`,
187
+ `export default class Foo extends Component {`,
188
+ ` <template>Entry</template>`,
189
+ `}`,
190
+ ].join("\n"),
191
+ },
192
+ { input: ["index.ts", "foo.gts"] },
193
+ );
194
+
195
+ expect(code).not.toContain("<template>");
196
+ expect(code).toContain("template(");
197
+ expect(code).toContain(`from "@glimmer/component"`);
198
+ // deduplicated: the class body appears exactly once, in foo's own chunk
199
+ expect(code.match(/extends Component/g)).toHaveLength(1);
200
+ expect(code).toContain(`from "./foo.js"`);
201
+
202
+ expect(code).toMatchInlineSnapshot(`
203
+ "import { template } from "@ember/template-compiler";
204
+ import Component from "@glimmer/component";
205
+ //#region foo.ts
206
+ var Foo = class extends Component {
207
+ static {
208
+ template(\`Entry\`, {
209
+ component: this,
210
+ eval() {
211
+ return eval(arguments[0]);
212
+ }
213
+ });
214
+ }
215
+ };
216
+ //#endregion
217
+ export { Foo as default };
218
+
219
+ import Foo from "./foo.js";
220
+ export { Foo };
221
+ "
222
+ `);
223
+ });
224
+
174
225
  it("emits sourcemaps for the specifier rewrite (no SOURCEMAP_BROKEN warnings)", async () => {
175
226
  const dir = await mkdtemp(path.join(tmpdir(), "ember-rolldown-map-"));
176
227
 
@@ -1,5 +1,5 @@
1
1
  import { Preprocessor } from "content-tag";
2
- import { existsSync } from "node:fs";
2
+ import { existsSync, realpathSync } from "node:fs";
3
3
  import { readFile, writeFile, readdir } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import type { Plugin } from "rolldown";
@@ -70,7 +70,28 @@ export function emberTransform(): Plugin {
70
70
  resolveId: {
71
71
  order: "pre",
72
72
  handler(id, importer) {
73
- if (!importer) return null;
73
+ // Entries have no importer: their ids come straight from the build
74
+ // config (tsdown expands entry globs to on-disk paths). Resolve a
75
+ // `.gts`/`.gjs` entry to the same virtual `.ts`/`.js` id an imported
76
+ // module gets, so the load hook compiles it via content-tag instead
77
+ // of the raw `<template>` source hitting the parser. Entries may be
78
+ // any extension — the emitted `.js`/`.d.ts` paths mirror the entry
79
+ // paths either way (that's how tsdown's dts support works).
80
+ if (!importer) {
81
+ if (!id.endsWith(".gts") && !id.endsWith(".gjs")) return null;
82
+ if (!existsSync(id)) return null;
83
+
84
+ // rolldown's default resolver realpaths entry ids (e.g. macOS
85
+ // /var -> /private/var). Match it, or this same file imported from
86
+ // another entry resolves to a second module id and gets duplicated
87
+ // into both chunks.
88
+ const fileName = realpathSync(path.resolve(id));
89
+
90
+ return {
91
+ id: fileName.replace(/\.gts$/, ".ts").replace(/\.gjs$/, ".js"),
92
+ meta: { fileName },
93
+ };
94
+ }
74
95
 
75
96
  const fileName = path.join(path.dirname(importer), id);
76
97
 
@@ -49,6 +49,9 @@ export default {
49
49
 
50
50
  return reasons.length === 0;
51
51
  },
52
+ readme() {
53
+ return "### GitHub Actions\n\nContinuous Integration (CI) is configured under `.github/workflows/ci.yml` for automated testing and linting.";
54
+ },
52
55
  };
53
56
 
54
57
  /**
@@ -62,4 +62,16 @@ export default {
62
62
 
63
63
  return true;
64
64
  },
65
+
66
+ /**
67
+ * @param {import('#utils/project.js').Project} project
68
+ */
69
+ readme(project) {
70
+ return `### Prettier
71
+
72
+ Code formatting is managed with [Prettier](https://prettier.io/).
73
+
74
+ - \`${project.runPrefix} format\` - Format code
75
+ - \`${project.runPrefix} lint:prettier\` - Check code formatting`;
76
+ },
65
77
  };
@@ -0,0 +1,176 @@
1
+ import { existsSync } from "node:fs";
2
+ import { writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { formatLabel } from "#utils/cli.js";
5
+
6
+ /**
7
+ * @param {import('#utils/project.js').Project} project
8
+ * @param {string} layerDocsMarkdown
9
+ */
10
+ function appReadme(project, layerDocsMarkdown) {
11
+ return `# ${project.name}
12
+
13
+ An Ember application created with \`ember.nvp\`.
14
+
15
+ ## Getting Started
16
+
17
+ ### Prerequisites
18
+
19
+ - Node.js >= 24
20
+ - ${project.packageManager}
21
+
22
+ ### Installation
23
+
24
+ ~~~sh
25
+ ${project.packageManager} install
26
+ ~~~
27
+
28
+ ### Development
29
+
30
+ To start the local development server:
31
+
32
+ ~~~sh
33
+ ${project.runPrefix} dev
34
+ ~~~
35
+
36
+ or
37
+
38
+ ~~~sh
39
+ ${project.runPrefix} start
40
+ ~~~
41
+ ${layerDocsMarkdown}`;
42
+ }
43
+
44
+ /**
45
+ * @param {import('#utils/project.js').Project} project
46
+ * @param {string} layerDocsMarkdown
47
+ */
48
+ function extensionReadme(project, layerDocsMarkdown) {
49
+ return `# ${project.name}
50
+
51
+ A browser extension using Ember created with \`ember.nvp\`.
52
+
53
+ ## Getting Started
54
+
55
+ ### Prerequisites
56
+
57
+ - Node.js >= 24
58
+ - ${project.packageManager}
59
+
60
+ ### Development
61
+
62
+ To start development:
63
+
64
+ ~~~sh
65
+ ${project.runPrefix} dev
66
+ ~~~
67
+
68
+ or
69
+
70
+ ~~~sh
71
+ ${project.runPrefix} start
72
+ ~~~
73
+
74
+ ### Building
75
+
76
+ To build the library:
77
+
78
+ ~~~sh
79
+ ${project.runPrefix} build
80
+ ~~~
81
+
82
+ or
83
+
84
+ ~~~sh
85
+ ${project.runPrefix} build:watch
86
+ ~~~
87
+ ${layerDocsMarkdown}`;
88
+ }
89
+
90
+ /**
91
+ * @param {import('#utils/project.js').Project} project
92
+ * @param {string} layerDocsMarkdown
93
+ */
94
+ function libraryReadme(project, layerDocsMarkdown) {
95
+ return `# ${project.name}
96
+
97
+ An Ember library/addon created with \`ember.nvp\`.
98
+
99
+ ## Getting Started
100
+
101
+ ### Prerequisites
102
+
103
+ - Node.js >= 24
104
+ - ${project.packageManager}
105
+
106
+ ### Development & Building
107
+
108
+ To build the library:
109
+
110
+ ~~~sh
111
+ ${project.runPrefix} build
112
+ ~~~
113
+ ${layerDocsMarkdown}`;
114
+ }
115
+
116
+ export default {
117
+ label: formatLabel("README.md", "generate project documentation"),
118
+ hint: "project README",
119
+
120
+ async defaultValue() {
121
+ return true;
122
+ },
123
+
124
+ /**
125
+ * @param {import('#utils/project.js').Project} project
126
+ */
127
+ async run(project) {
128
+ // Collect layer docs
129
+ const layerDocs = [];
130
+ for (const layer of project.desires.layers) {
131
+ if (layer.name === "readme" || !layer.readme) {
132
+ continue;
133
+ }
134
+
135
+ let doc;
136
+ if (typeof layer.readme === "function") {
137
+ doc = await layer.readme(project);
138
+ } else if (typeof layer.readme === "string") {
139
+ doc = layer.readme;
140
+ }
141
+
142
+ if (doc && typeof doc === "string" && doc.trim().length > 0) {
143
+ layerDocs.push(doc.trim());
144
+ }
145
+ }
146
+
147
+ let layerDocsMarkdown = "";
148
+ if (layerDocs.length > 0) {
149
+ layerDocsMarkdown = `\n## Features & Tooling\n\n${layerDocs.join("\n\n")}\n`;
150
+ }
151
+
152
+ // Merge the layers' snippets into the main readme template
153
+ let content;
154
+ switch (project.type) {
155
+ case "extension":
156
+ content = extensionReadme(project, layerDocsMarkdown);
157
+ break;
158
+ case "library":
159
+ content = libraryReadme(project, layerDocsMarkdown);
160
+ break;
161
+ default:
162
+ content = appReadme(project, layerDocsMarkdown);
163
+ break;
164
+ }
165
+
166
+ const targetPath = join(project.directory, "README.md");
167
+ await writeFile(targetPath, `${content.trim()}\n`, "utf-8");
168
+ },
169
+
170
+ /**
171
+ * @param {import('#utils/project.js').Project} project
172
+ */
173
+ async isSetup(project) {
174
+ return existsSync(join(project.directory, "README.md"));
175
+ },
176
+ };
@@ -120,6 +120,17 @@ export default {
120
120
 
121
121
  return reasons.length === 0;
122
122
  },
123
+
124
+ /**
125
+ * @param {import('#utils/project.js').Project} project
126
+ */
127
+ readme(project) {
128
+ return `### TypeScript
129
+
130
+ This project uses TypeScript and Glint for static type checking.
131
+
132
+ - \`${project.runPrefix} lint:types\` - Typecheck code with Glint/TypeScript`;
133
+ },
123
134
  };
124
135
 
125
136
  /**
@@ -101,4 +101,15 @@ export default {
101
101
 
102
102
  return reasons.length === 0;
103
103
  },
104
+
105
+ /**
106
+ * @param {import('#utils/project.js').Project} project
107
+ */
108
+ readme(project) {
109
+ return `### Testing (Vitest)
110
+
111
+ Testing is configured with [Vitest](https://vitest.dev/).
112
+
113
+ - \`${project.runPrefix} test\` - Run tests`;
114
+ },
104
115
  };
@@ -47,6 +47,13 @@ export class Project {
47
47
  return this.desires.packageManager;
48
48
  }
49
49
 
50
+ /**
51
+ * @type {string}
52
+ */
53
+ get runPrefix() {
54
+ return this.packageManager === "npm" ? "npm run" : "pnpm";
55
+ }
56
+
50
57
  /**
51
58
  * @type {string}
52
59
  */
@@ -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",