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.
@@ -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
  },
@@ -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,59 @@ 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 without the `typescript`
59
+ * package have no declarations to emit, so there is nothing to check.
60
+ * `tsconfig: false` is different: it is an explicit opt-out, and combined with
61
+ * declaration emit it produces a confusing "Source file not found" from the
62
+ * tsc-based pipeline, so it errors here instead.
24
63
  */
25
64
  export function emberIsolatedDeclarations(): Plugin {
65
+ let tsconfigOption: string | boolean | undefined;
66
+ let emitsDeclarations = true;
67
+
26
68
  return {
27
69
  name: "ember:isolated-declarations",
28
70
 
71
+ // Not called under plain rolldown, which leaves `tsconfigOption` undefined
72
+ // -- i.e. the cwd's tsconfig.json, the only thing it could mean there.
73
+ tsdownConfig(config: UserConfig) {
74
+ tsconfigOption = config.tsconfig;
75
+ // `emberConfig()` turns dts on unless the library turned it off, so an
76
+ // explicit `false` is the only "no declarations" signal. Hook order
77
+ // between plugins isn't guaranteed, so don't rely on it having run.
78
+ emitsDeclarations = config.dts !== false;
79
+ },
80
+
29
81
  async buildStart() {
30
- const tsconfigPath = resolve("tsconfig.json");
82
+ if (tsconfigOption === false) {
83
+ if (!emitsDeclarations) return;
84
+
85
+ // Without a tsconfig there is no `isolatedDeclarations`, so tsdown
86
+ // falls back to the tsc-based declaration pipeline -- which reads
87
+ // source files from disk and dies with "Source file not found" on
88
+ // every `.gts`/`.gjs`, since those only exist compiled in the module
89
+ // graph. Say so here instead of leaving that to be decoded.
90
+ this.error(
91
+ `\`tsconfig: false\` cannot be combined with declaration emit.\n\n` +
92
+ `Declarations are emitted by the isolated-declarations pipeline, which needs a ` +
93
+ `tsconfig setting "compilerOptions.isolatedDeclarations": true -- it is the only ` +
94
+ `pipeline that can see compiled .gts/.gjs modules.\n\n` +
95
+ `Either point tsdown's \`tsconfig\` option at such a config, or set \`dts: false\` ` +
96
+ `if this library ships no types.`,
97
+ );
98
+ }
99
+
100
+ const tsconfigPath = resolveTsconfigPath(tsconfigOption);
31
101
 
32
- if (!existsSync(tsconfigPath)) return;
102
+ if (!tsconfigPath || !existsSync(tsconfigPath)) return;
33
103
 
34
104
  let ts: typeof import("typescript");
35
105
 
@@ -57,7 +127,10 @@ export function emberIsolatedDeclarations(): Plugin {
57
127
  `isolated-declarations pipeline, which reads compiled modules from the bundler's ` +
58
128
  `module graph -- the tsc-based pipeline reads from disk and cannot see them.\n\n` +
59
129
  `Isolated declarations require exported values to have explicit type annotations, ` +
60
- `e.g. \`export const X: TOC<Sig> = <template>...\`.`,
130
+ `e.g. \`export const X: TOC<Sig> = <template>...\`.\n\n` +
131
+ `If this tsconfig also covers dev-only code (a demo app, in-package tests) that ` +
132
+ `should not be constrained this way, point tsdown's \`tsconfig\` option at a ` +
133
+ `publish-only tsconfig that sets the flag and covers just the published sources.`,
61
134
  );
62
135
  }
63
136
  },
@@ -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
  */