ember.nvp 1.1.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.
@@ -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() {},
@@ -170,4 +173,87 @@ describe("emberTransform (full plugin via rolldown)", () => {
170
173
  "
171
174
  `);
172
175
  });
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
+
225
+ it("emits sourcemaps for the specifier rewrite (no SOURCEMAP_BROKEN warnings)", async () => {
226
+ const dir = await mkdtemp(path.join(tmpdir(), "ember-rolldown-map-"));
227
+
228
+ const files = {
229
+ // index.ts contains a `.gts` specifier, so it goes through the rewrite.
230
+ "index.ts": `export { default as Foo } from './foo.gts';`,
231
+ "foo.gts": `<template>Foo</template>`,
232
+ };
233
+
234
+ for (const [relative, source] of Object.entries(files)) {
235
+ await writeFile(path.join(dir, relative), source, "utf8");
236
+ }
237
+
238
+ const warnings: string[] = [];
239
+ const build = await rolldown({
240
+ input: path.join(dir, "index.ts"),
241
+ plugins: [emberTransform()],
242
+ external: (id) => !(id.startsWith(".") || path.isAbsolute(id)),
243
+ onwarn(warning) {
244
+ warnings.push(warning.code ?? String(warning));
245
+ },
246
+ });
247
+
248
+ const { output } = await build.generate({ format: "es", sourcemap: true });
249
+
250
+ expect(warnings.filter((code) => code === "SOURCEMAP_BROKEN")).toEqual([]);
251
+
252
+ const chunk = output.find((entry) => entry.type === "chunk" && "map" in entry);
253
+ expect(chunk).toBeDefined();
254
+ // The composed map traces through the rewrite back to the original .gts
255
+ // source (content-tag's map survives the specifier-rewrite pass).
256
+ const sources = (chunk as { map?: { sources: string[] } }).map?.sources ?? [];
257
+ expect(sources.some((source) => source.endsWith("foo.gts"))).toBe(true);
258
+ });
173
259
  });
@@ -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";
@@ -38,58 +38,96 @@ async function fixDtsExtensionsInDir(dir: string): Promise<void> {
38
38
  }
39
39
  }
40
40
 
41
+ /**
42
+ * A sourcemap mapping every generated line to the same line of the input.
43
+ *
44
+ * `AAAA` is the [0, 0, 0, 0] VLQ segment (first line maps to source 0, line 0,
45
+ * column 0); `AACA` is [0, 0, +1, 0] (each following line advances the source
46
+ * line by one). Exact at line granularity, which is all the specifier rewrite
47
+ * can disturb.
48
+ */
49
+ function lineIdentityMap(id: string, source: string) {
50
+ return {
51
+ version: 3,
52
+ sources: [id],
53
+ sourcesContent: [source],
54
+ names: [],
55
+ mappings: source
56
+ .split("\n")
57
+ .map((_, line) => (line === 0 ? "AAAA" : "AACA"))
58
+ .join(";"),
59
+ };
60
+ }
61
+
41
62
  /**
42
63
  * Maps `.gts` -> `.ts` and `.gjs` -> `.js` (so rolldown can identify them as
43
64
  * ts/js) and preprocesses `<template>` via content-tag.
44
65
  */
45
66
  export function emberTransform(): Plugin {
46
- const virtualFiles = new Set<string>();
47
-
48
67
  return {
49
68
  name: "ember:transform",
50
69
 
51
70
  resolveId: {
52
71
  order: "pre",
53
72
  handler(id, importer) {
54
- 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
+ }
55
95
 
56
96
  const fileName = path.join(path.dirname(importer), id);
57
97
 
58
- if (id.endsWith(".gts") && !importer.endsWith(".d.ts")) {
59
- virtualFiles.add(fileName);
98
+ if (id.endsWith(".gts")) {
60
99
  return {
61
100
  id: fileName.replace(/\.gts$/, ".ts"),
62
101
  meta: { fileName },
63
102
  };
64
103
  }
65
104
 
66
- if (id.endsWith(".gjs") && !importer.endsWith(".d.ts")) {
67
- virtualFiles.add(fileName);
105
+ if (id.endsWith(".gjs")) {
68
106
  return {
69
107
  id: fileName.replace(/\.gjs$/, ".js"),
70
108
  meta: { fileName },
71
109
  };
72
110
  }
73
111
 
74
- if (id.endsWith(".d.ts") && importer.endsWith(".d.ts")) {
75
- const gtsFile = fileName.replace(".d.ts", ".gts");
76
- if (virtualFiles.has(gtsFile)) {
77
- return fileName;
78
- }
79
- }
80
-
81
- if (id.endsWith(".ts") && !importer.endsWith(".d.ts")) {
112
+ // A `.ts`/`.js` specifier whose only backing file is a `.gts`/`.gjs`
113
+ // resolves to the virtual module. This must also serve `.d.ts`
114
+ // importers: rolldown-plugin-dts resolves a declaration module's
115
+ // imports through the plugin pipeline (`this.resolve`), and — when the
116
+ // resolution is a source file — loads it (registering its declaration)
117
+ // before mapping the import to the declaration id. Virtual modules
118
+ // exist nowhere else, so if we don't answer here the import either
119
+ // fails to resolve (no file on disk) or, worse, resolves to a
120
+ // declaration id that was never registered.
121
+ if (id.endsWith(".ts")) {
82
122
  const gtsFileName = fileName.replace(/\.ts$/, ".gts");
83
123
  if (existsSync(gtsFileName) && !existsSync(fileName)) {
84
- virtualFiles.add(gtsFileName);
85
124
  return { id: fileName, meta: { fileName: gtsFileName } };
86
125
  }
87
126
  }
88
127
 
89
- if (id.endsWith(".js") && !importer.endsWith(".d.ts")) {
128
+ if (id.endsWith(".js")) {
90
129
  const gjsFileName = fileName.replace(/\.js$/, ".gjs");
91
130
  if (existsSync(gjsFileName) && !existsSync(fileName)) {
92
- virtualFiles.add(gjsFileName);
93
131
  return { id: fileName, meta: { fileName: gjsFileName } };
94
132
  }
95
133
  }
@@ -105,7 +143,18 @@ export function emberTransform(): Plugin {
105
143
  },
106
144
  async handler(id) {
107
145
  const meta = this.getModuleInfo(id)?.meta ?? {};
108
- const fileName = meta?.fileName;
146
+ let fileName = meta?.fileName;
147
+
148
+ // A virtual id can be loaded without having passed through our
149
+ // resolveId (rolldown-plugin-dts calls `this.load({ id })` with just
150
+ // the id), so no meta is attached: recover the backing file from disk.
151
+ if (!fileName && !existsSync(id)) {
152
+ if (id.endsWith(".ts") && existsSync(id.replace(/\.ts$/, ".gts"))) {
153
+ fileName = id.replace(/\.ts$/, ".gts");
154
+ } else if (id.endsWith(".js") && existsSync(id.replace(/\.js$/, ".gjs"))) {
155
+ fileName = id.replace(/\.js$/, ".gjs");
156
+ }
157
+ }
109
158
 
110
159
  if (fileName) {
111
160
  this.addWatchFile(fileName);
@@ -132,21 +181,26 @@ export function emberTransform(): Plugin {
132
181
  id: /\.(js|ts)$/,
133
182
  },
134
183
  handler(input, id) {
135
- if (input.includes(".gts") && id.endsWith(".d.ts")) {
136
- return input.replace(
137
- /(['"`])((?:\.\.?\/|\/|@|[A-Za-z0-9_\-])[^'"]*?\.gts)\1/g,
138
- (_m, q, p) => `${q}${p.replace(/\.gts$/, ".d.ts")}${q}`,
139
- );
140
- }
141
-
142
- if (input.includes(".gts") && !id.endsWith(".d.ts")) {
143
- return input.replace(
144
- /(['"`])((?:\.\.?\/|\/|@|[A-Za-z0-9_\-])[^'"]*?\.gts)\1/g,
145
- (_m, q, p) => `${q}${p.replace(/\.gts$/, ".ts")}${q}`,
146
- );
184
+ // Rewrite `.gts` specifiers to `.ts` — the (virtual) source id — in
185
+ // runtime modules AND declaration modules alike. Declaration modules
186
+ // must NOT be rewritten to `.d.ts`: rolldown-plugin-dts's resolver
187
+ // treats a source-file resolution as "load it (registering its
188
+ // declaration), then map to the declaration id", while a `.d.ts` id is
189
+ // returned as-is — unloadable when the module isn't registered yet
190
+ // (e.g. the `.gts` is only ever type-imported).
191
+ const output = input.replace(
192
+ /(['"`])((?:\.\.?\/|\/|@|[A-Za-z0-9_\-])[^'"]*?\.gts)\1/g,
193
+ (_m, q, p) => `${q}${p.replace(/\.gts$/, ".ts")}${q}`,
194
+ );
195
+
196
+ if (output === input) {
197
+ return null;
147
198
  }
148
199
 
149
- return input;
200
+ // The rewrite only shortens import specifiers in place, so a
201
+ // line-identity map is accurate to the line (and to the column for
202
+ // everything before the first rewritten specifier on a line).
203
+ return { code: output, map: lineIdentityMap(id, input) };
150
204
  },
151
205
  },
152
206
 
@@ -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
  *