ember.nvp 1.13.0 → 1.15.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,311 @@
1
+ import { packageJson } from "ember-apply";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { writeFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import semver from "semver";
6
+ import { getLatest } from "#utils/npm.js";
7
+ import { readJSON } from "#utils/fs.js";
8
+ import { listFiles, move } from "./files.js";
9
+
10
+ /**
11
+ * What the ember.nvp library base builds with.
12
+ * Read from the base, so that a migrated library matches a generated one.
13
+ */
14
+ const BASE_MANIFEST = readJSON(
15
+ join(import.meta.dirname, "../bases/minimal-library/files/package.json"),
16
+ );
17
+
18
+ /**
19
+ * @param {string} root
20
+ * @param {string} srcDir
21
+ * @returns {boolean} whether the source has or imports CSS
22
+ */
23
+ export function usesCss(root, srcDir) {
24
+ let files = listFiles(root, srcDir);
25
+
26
+ if (files.some((file) => file.endsWith(".css"))) return true;
27
+
28
+ return files.some(
29
+ (file) =>
30
+ /\.(js|ts|gjs|gts)$/.test(file) &&
31
+ /import\s+["'][^"']+\.css["']/.test(readFileSync(join(root, file), "utf-8")),
32
+ );
33
+ }
34
+
35
+ /**
36
+ * Source extensions that build to `.js`
37
+ */
38
+ const SOURCE_EXTENSIONS = "{js,ts,gjs,gts}";
39
+
40
+ /**
41
+ * Entries in tsdown's glob syntax.
42
+ *
43
+ * `@embroider/addon-dev` matches `publicEntrypoints` against the built `.js` files.
44
+ * tsdown matches `entry` against the source files, which have any source extension.
45
+ *
46
+ * **\/*.js → ./src/**\/*.{js,ts,gjs,gts}
47
+ * index.js → ./src/index.{js,ts,gjs,gts}
48
+ *
49
+ * Declarations and co-located tests are never entries.
50
+ *
51
+ * @param {string} srcDir
52
+ * @param {string[]} publicEntrypoints globs relative to `srcDir`, for built files
53
+ * @returns {string[]}
54
+ */
55
+ export function entriesFor(srcDir, publicEntrypoints) {
56
+ /** @type {string[]} */
57
+ let entries = [];
58
+
59
+ let everything = publicEntrypoints.some((glob) => glob === "**/*.js" || glob === "**/*");
60
+ let globs = everything ? ["**/*.js"] : publicEntrypoints;
61
+
62
+ for (let glob of globs) {
63
+ if (!glob.endsWith(".js") && !glob.endsWith("*")) continue;
64
+
65
+ let source = glob.endsWith(".js")
66
+ ? glob.slice(0, -".js".length) + `.${SOURCE_EXTENSIONS}`
67
+ : glob;
68
+
69
+ entries.push(`./${srcDir}/${source}`);
70
+ }
71
+
72
+ entries.push(`!./${srcDir}/**/*.d.ts`, `!./${srcDir}/**/*-test.${SOURCE_EXTENSIONS}`);
73
+
74
+ return entries;
75
+ }
76
+
77
+ /**
78
+ * @typedef {object} TsdownSetup
79
+ * @property {string[]} entry
80
+ * @property {string[] | undefined} appReexports globs for built files, as `@embroider/addon-dev` takes them
81
+ * @property {boolean} css whether the source imports CSS
82
+ * @property {boolean} typescript
83
+ * @property {string | undefined} outDir when it is not `dist`
84
+ */
85
+
86
+ /**
87
+ * The same shape as the library base's tsdown.config.js,
88
+ * so that layers can add properties to it.
89
+ *
90
+ * @param {TsdownSetup} setup
91
+ * @returns {string}
92
+ */
93
+ export function tsdownConfig({ entry, appReexports, css, typescript, outDir }) {
94
+ let lines = [
95
+ `import { defineConfig } from "tsdown";`,
96
+ `import { ember } from "@nullvoxpopuli/ember-rolldown";`,
97
+ ];
98
+
99
+ if (appReexports) {
100
+ lines.push(`import { appReexports } from "@nullvoxpopuli/ember-rolldown/app-reexports";`);
101
+ }
102
+
103
+ lines.push("", "export default defineConfig({", ` entry: ${JSON.stringify(entry)},`);
104
+
105
+ if (outDir) {
106
+ lines.push(` outDir: ${JSON.stringify(outDir)},`);
107
+ }
108
+
109
+ if (!typescript) {
110
+ lines.push(" dts: false,");
111
+ }
112
+
113
+ if (css) {
114
+ // keeps `import "./x.css"` in the output, so that apps load the styles
115
+ lines.push(" css: { inject: true },");
116
+ }
117
+
118
+ let plugins = appReexports ? `ember(), appReexports(${JSON.stringify(appReexports)})` : "ember()";
119
+
120
+ lines.push(` plugins: [${plugins}],`, "});", "");
121
+
122
+ return lines.join("\n");
123
+ }
124
+
125
+ /**
126
+ * Builds with tsdown instead of the old blueprint's build:
127
+ * - writes tsdown.config.js
128
+ * - points the build scripts at tsdown
129
+ * - adds the base's build dependencies
130
+ * - publishes declarations from `dist`, where tsdown writes them
131
+ *
132
+ * @param {import('#utils/project.js').Project} project
133
+ * @param {TsdownSetup} setup
134
+ */
135
+ export async function buildWithTsdown(project, setup) {
136
+ await writeFile(project.path("tsdown.config.js"), tsdownConfig(setup));
137
+
138
+ await packageJson.modify((json) => {
139
+ json.type = "module";
140
+ json.scripts ||= {};
141
+ json.scripts.build = BASE_MANIFEST.scripts.build;
142
+ json.scripts.prepack = BASE_MANIFEST.scripts.prepack;
143
+ json.scripts.start = BASE_MANIFEST.scripts.start;
144
+
145
+ if (json.exports) json.exports = fromDeclarationsToDist(json.exports);
146
+ if (json.typesVersions) json.typesVersions = fromDeclarationsToDist(json.typesVersions);
147
+ if (typeof json.types === "string") json.types = fromDeclarationsToDist(json.types);
148
+
149
+ if (Array.isArray(json.files)) {
150
+ json.files = json.files.filter(
151
+ /** @param {string} file */ (file) => file !== "declarations" && file !== "declarations/",
152
+ );
153
+ }
154
+ }, project.directory);
155
+
156
+ let base = BASE_MANIFEST.devDependencies;
157
+ /** @type {Record<string, string>} */
158
+ let wanted = {
159
+ tsdown: base.tsdown,
160
+ "@nullvoxpopuli/ember-rolldown": base["@nullvoxpopuli/ember-rolldown"],
161
+ };
162
+
163
+ if (setup.typescript) {
164
+ wanted["@ember/library-tsconfig"] = base["@ember/library-tsconfig"];
165
+ }
166
+
167
+ if (setup.css) {
168
+ // released in lockstep with tsdown
169
+ wanted["@tsdown/css"] = base.tsdown;
170
+ }
171
+
172
+ await packageJson.addDevDependencies(await getLatest(wanted), project.directory);
173
+ }
174
+
175
+ /**
176
+ * The qunit layer's test app boots with the strict resolver of ember-source 7.2.
177
+ *
178
+ * A library's own ember-source is a devDependency, for its tests.
179
+ * Moving it forward changes nothing for the apps that use the library.
180
+ *
181
+ * @param {import('#utils/project.js').Project} project
182
+ */
183
+ export async function useEmberSourceForTests(project) {
184
+ if (!project.wantsLayer("qunit")) return;
185
+
186
+ let manifest = await packageJson.read(project.directory);
187
+ let range = manifest.devDependencies?.["ember-source"];
188
+ let wanted = BASE_MANIFEST.devDependencies["ember-source"];
189
+
190
+ if (range && !semver.validRange(range)) return;
191
+
192
+ let current = range ? semver.minVersion(range) : undefined;
193
+ let minimum = /** @type {semver.SemVer} */ (semver.minVersion(wanted));
194
+
195
+ if (current && semver.gte(current, minimum)) return;
196
+
197
+ await packageJson.addDevDependencies(
198
+ await getLatest({ "ember-source": wanted }),
199
+ project.directory,
200
+ );
201
+ }
202
+
203
+ /**
204
+ * @param {unknown} value a package.json field
205
+ * @returns {any} the same field, with `declarations/` paths moved to `dist/`
206
+ */
207
+ function fromDeclarationsToDist(value) {
208
+ if (typeof value === "string") {
209
+ return value.replace(/^(\.\/)?declarations\//, "$1dist/");
210
+ }
211
+
212
+ if (Array.isArray(value)) {
213
+ return value.map(fromDeclarationsToDist);
214
+ }
215
+
216
+ if (value && typeof value === "object") {
217
+ /** @type {Record<string, unknown>} */
218
+ let result = {};
219
+
220
+ for (let [key, inner] of Object.entries(value)) {
221
+ result[key] = fromDeclarationsToDist(inner);
222
+ }
223
+
224
+ return result;
225
+ }
226
+
227
+ return value;
228
+ }
229
+
230
+ /**
231
+ * Config files in the project root and in `config/` that Node must load as CommonJS.
232
+ * Only files that ember.nvp does not remove are listed.
233
+ */
234
+ const COMMONJS_CANDIDATES = [
235
+ ".eslintrc.js",
236
+ ".prettierrc.js",
237
+ ".stylelintrc.js",
238
+ ".template-lintrc.js",
239
+ "addon-main.js",
240
+ "babel.config.js",
241
+ "babel.publish.config.js",
242
+ ];
243
+
244
+ /**
245
+ * ember.nvp projects are `"type": "module"`,
246
+ * so a `.js` file that uses `module.exports` must become `.cjs`.
247
+ *
248
+ * References in package.json follow the rename.
249
+ *
250
+ * @param {import('#utils/project.js').Project} project
251
+ * @returns {Promise<string[]>} the renamed files
252
+ */
253
+ export async function renameCommonJSFiles(project) {
254
+ /** @type {string[]} */
255
+ let renamed = [];
256
+
257
+ for (let file of COMMONJS_CANDIDATES) {
258
+ let path = project.path(file);
259
+
260
+ if (!existsSync(path)) continue;
261
+
262
+ let source = readFileSync(path, "utf-8");
263
+
264
+ if (!/\bmodule\.exports\b|\brequire\(/.test(source)) continue;
265
+
266
+ let to = file.replace(/\.js$/, ".cjs");
267
+
268
+ await move(project, file, to);
269
+ renamed.push(file);
270
+
271
+ await packageJson.modify((json) => {
272
+ json.exports &&= renameIn(json.exports, file, to);
273
+ json.files &&= renameIn(json.files, file, to);
274
+
275
+ if (json["ember-addon"]?.main) {
276
+ json["ember-addon"].main = renameIn(json["ember-addon"].main, file, to);
277
+ }
278
+ }, project.directory);
279
+ }
280
+
281
+ return renamed;
282
+ }
283
+
284
+ /**
285
+ * @param {unknown} value
286
+ * @param {string} from
287
+ * @param {string} to
288
+ * @returns {any}
289
+ */
290
+ function renameIn(value, from, to) {
291
+ if (typeof value === "string") {
292
+ return value === from || value === `./${from}` ? value.replace(from, to) : value;
293
+ }
294
+
295
+ if (Array.isArray(value)) {
296
+ return value.map((inner) => renameIn(inner, from, to));
297
+ }
298
+
299
+ if (value && typeof value === "object") {
300
+ /** @type {Record<string, unknown>} */
301
+ let result = {};
302
+
303
+ for (let [key, inner] of Object.entries(value)) {
304
+ result[key] = renameIn(inner, from, to);
305
+ }
306
+
307
+ return result;
308
+ }
309
+
310
+ return value;
311
+ }
@@ -101,6 +101,75 @@ export interface Base {
101
101
  run: (project: Project) => Promise<void>;
102
102
  }
103
103
 
104
+ /**
105
+ * Something a migration found, for the user to act on.
106
+ */
107
+ export interface Finding {
108
+ /**
109
+ * What was found, as a short noun phrase
110
+ */
111
+ title: string;
112
+ /**
113
+ * The files, packages, or options where it was found
114
+ */
115
+ where?: string[];
116
+ /**
117
+ * What to do about it
118
+ */
119
+ action: string;
120
+ }
121
+
122
+ export interface MigrationReport {
123
+ /**
124
+ * Features that ember.nvp does not support.
125
+ * Any of these stops the migration, before anything is written.
126
+ */
127
+ unsupported: Finding[];
128
+ /**
129
+ * Work for the user after the migration
130
+ */
131
+ todo: Finding[];
132
+ /**
133
+ * Layers that replace tooling from the old blueprint.
134
+ * The CLI selects them by default.
135
+ */
136
+ layers: string[];
137
+ }
138
+
139
+ /**
140
+ * Moves a project from an older blueprint to an ember.nvp base.
141
+ */
142
+ export interface Migration {
143
+ /**
144
+ * The blueprint's package name
145
+ */
146
+ label: string;
147
+ /**
148
+ * The project type after the migration
149
+ */
150
+ type: ProjectType;
151
+ /**
152
+ * Whether the directory holds a project from this blueprint
153
+ */
154
+ detect: (directory: string) => boolean;
155
+ /**
156
+ * Reads the project and changes nothing.
157
+ *
158
+ * Files are read from `project.directory`.
159
+ * Installed dependencies are read from `project.desires.path`,
160
+ * because a stage has no node_modules.
161
+ */
162
+ check: (project: Project) => Promise<MigrationReport>;
163
+ /**
164
+ * Removes or rewrites the old blueprint's files,
165
+ * so that the base and the layers can apply their own.
166
+ *
167
+ * Runs before the base, and only when `check` found nothing unsupported.
168
+ * Adds to `report.todo` what depends on the selected layers.
169
+ */
170
+ run: (project: Project, report: MigrationReport) => Promise<void>;
171
+ }
172
+
104
173
  export interface DiscoveredLayer extends Layer {
105
174
  /**
106
175
  * The unique name of the layer.
@@ -161,6 +161,7 @@ describe("layer: qunit", () => {
161
161
  "README.md",
162
162
  "config/test/babel.config.js",
163
163
  "config/test/testem.cjs",
164
+ "config/tsconfig.publish.json",
164
165
  "package.json",
165
166
  "src/index.ts",
166
167
  "tests/rendering/.gitkeep",
@@ -258,15 +259,38 @@ describe("layer: qunit", () => {
258
259
  `);
259
260
  });
260
261
 
261
- it("leaves the publish build config alone", async () => {
262
+ it("builds with a publish tsconfig, so tsconfig.json can cover the tests", async () => {
262
263
  expect(await project.read("tsdown.config.js")).toMatchInlineSnapshot(`
263
264
  "import { defineConfig } from "tsdown";
264
265
  import { ember } from "@nullvoxpopuli/ember-rolldown";
265
-
266
- export default defineConfig({
267
- entry: ["./src/index.ts"],
268
- plugins: [ember()],
269
- });
266
+ export default defineConfig({ entry: ["./src/index.ts"], plugins: [ember()], tsconfig: "./config/tsconfig.publish.json" })"
267
+ `);
268
+ expect(await project.read("config/tsconfig.publish.json")).toMatchInlineSnapshot(`
269
+ "{
270
+ "extends": "../tsconfig.json",
271
+ "include": ["../src"],
272
+ "compilerOptions": {
273
+ "rootDir": "../src",
274
+ "isolatedDeclarations": true
275
+ }
276
+ }
277
+ "
278
+ `);
279
+ expect(await project.read("tsconfig.json")).toMatchInlineSnapshot(`
280
+ "{
281
+ "extends": "@ember/library-tsconfig",
282
+ "include": ["src", "tests"],
283
+ "compilerOptions": {
284
+ "lib": ["esnext", "dom", "dom.iterable"],
285
+ "types": ["ember-source/types", "@glint/ember-tsc/types"],
286
+ },
287
+ "contentMappers": [
288
+ {
289
+ "package": "ember-content-mapper",
290
+ "extensions": [".gts", ".gjs"],
291
+ },
292
+ ],
293
+ }
270
294
  "
271
295
  `);
272
296
  });
@@ -292,6 +316,29 @@ describe("layer: qunit", () => {
292
316
  });
293
317
  });
294
318
 
319
+ describe("library (TypeScript) with eslint", () => {
320
+ it("lints the tests", { timeout: 300_000 }, async () => {
321
+ const project = await generate({
322
+ type: "library",
323
+ name: "my-lib",
324
+ layers: ["eslint-bundled", "qunit", "typescript"],
325
+ });
326
+ dirs.push(project.directory);
327
+
328
+ let install = await execa("pnpm install", { cwd: project.directory, shell: true });
329
+ expect(install.exitCode).toBe(0);
330
+
331
+ // eslint's project service cannot parse a file that no tsconfig includes
332
+ let lint = await execa("pnpm lint:eslint", {
333
+ cwd: project.directory,
334
+ shell: true,
335
+ reject: false,
336
+ all: true,
337
+ });
338
+ expect(lint.exitCode, lint.all).toBe(0);
339
+ });
340
+ });
341
+
295
342
  describe("library (JavaScript)", () => {
296
343
  let project: Project;
297
344