ember.nvp 1.14.0 → 1.16.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.
Files changed (35) hide show
  1. package/.release-plan.json +37 -5
  2. package/CHANGELOG.md +31 -0
  3. package/README.md +28 -0
  4. package/package.json +2 -1
  5. package/packages/rolldown/package.json +2 -2
  6. package/packages/rolldown/src/transform.test.ts +3 -3
  7. package/packages/vite/package.json +4 -4
  8. package/packages/vite/src/ember.test.ts +38 -0
  9. package/packages/vite/src/ember.ts +38 -5
  10. package/src/bases/minimal-app/files/package.json +2 -2
  11. package/src/bases/minimal-extension/files/package.json +2 -2
  12. package/src/cli/generator.js +10 -0
  13. package/src/cli/index.js +19 -2
  14. package/src/cli/questions/layers.js +3 -2
  15. package/src/cli/questions/migration.js +69 -0
  16. package/src/index.js +1 -0
  17. package/src/layers/qunit/index.js +1 -1
  18. package/src/migration-from/addons.js +154 -0
  19. package/src/migration-from/app.js +1117 -0
  20. package/src/migration-from/at-ember/addon-blueprint/index.js +515 -0
  21. package/src/migration-from/at-ember/addon-blueprint/rollup.js +188 -0
  22. package/src/migration-from/at-ember/app-blueprint/index.js +60 -0
  23. package/src/migration-from/at-ember-tooling/classic-build-addon-blueprint/index.js +601 -0
  24. package/src/migration-from/at-ember-tooling/classic-build-app-blueprint/index.js +118 -0
  25. package/src/migration-from/babel.js +207 -0
  26. package/src/migration-from/ember-cli-build.js +238 -0
  27. package/src/migration-from/files.js +120 -0
  28. package/src/migration-from/findings.js +69 -0
  29. package/src/migration-from/index.js +81 -0
  30. package/src/migration-from/javascript.js +217 -0
  31. package/src/migration-from/layers.js +170 -0
  32. package/src/migration-from/library.js +311 -0
  33. package/src/utils/types.ts +69 -0
  34. package/test/ember.nvp/migration-from-fixtures.ts +506 -0
  35. package/test/ember.nvp/migration-from.test.ts +475 -0
@@ -0,0 +1,69 @@
1
+ /**
2
+ * @typedef {import('#types').Finding} Finding
3
+ */
4
+
5
+ /**
6
+ * Past this many, a finding's `where` list ends with "and N more".
7
+ */
8
+ const LISTED = 8;
9
+
10
+ /**
11
+ * Thrown when a project uses features that ember.nvp cannot migrate.
12
+ *
13
+ * Nothing is written before this is thrown.
14
+ */
15
+ export class MigrationError extends Error {
16
+ /**
17
+ * @param {string} from the old blueprint
18
+ * @param {Finding[]} findings
19
+ */
20
+ constructor(from, findings) {
21
+ super(
22
+ `ember.nvp cannot migrate this project from ${from}.\n` +
23
+ `Fix these items, then run ember.nvp again:\n\n` +
24
+ formatFindings(findings),
25
+ );
26
+
27
+ this.name = "MigrationError";
28
+ this.from = from;
29
+ this.findings = findings;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * A numbered list:
35
+ *
36
+ * 1. Templates in .hbs files
37
+ * - src/components/foo.hbs
38
+ * Move each template into its component as <template>.
39
+ *
40
+ * @param {Finding[]} findings
41
+ * @returns {string}
42
+ */
43
+ export function formatFindings(findings) {
44
+ /** @type {string[]} */
45
+ let lines = [];
46
+
47
+ for (let i = 0; i < findings.length; i++) {
48
+ let finding = /** @type {Finding} */ (findings[i]);
49
+ let where = finding.where ?? [];
50
+
51
+ lines.push(`${i + 1}. ${finding.title}`);
52
+
53
+ for (let j = 0; j < where.length && j < LISTED; j++) {
54
+ lines.push(` - ${where[j]}`);
55
+ }
56
+
57
+ if (where.length > LISTED) {
58
+ lines.push(` - and ${where.length - LISTED} more`);
59
+ }
60
+
61
+ for (let line of finding.action.split("\n")) {
62
+ lines.push(` ${line}`);
63
+ }
64
+
65
+ lines.push("");
66
+ }
67
+
68
+ return lines.join("\n").trimEnd();
69
+ }
@@ -0,0 +1,81 @@
1
+ import addonBlueprint from "./at-ember/addon-blueprint/index.js";
2
+ import appBlueprint from "./at-ember/app-blueprint/index.js";
3
+ import classicAddon from "./at-ember-tooling/classic-build-addon-blueprint/index.js";
4
+ import classicApp from "./at-ember-tooling/classic-build-app-blueprint/index.js";
5
+ import { MigrationError } from "./findings.js";
6
+
7
+ export { MigrationError, formatFindings } from "./findings.js";
8
+
9
+ /**
10
+ * The older blueprints that ember.nvp migrates from.
11
+ *
12
+ * Detection asks them in this order.
13
+ * The markers of each one are its own, so the order only decides ties:
14
+ * - a v2 addon builds with rollup and @embroider/addon-dev
15
+ * - a classic addon has an index.js for ember-cli, and a dummy app in tests/
16
+ * - a Vite app still has an ember-cli-build for @embroider/compat
17
+ * - a classic app has an ember-cli-build without Vite
18
+ *
19
+ * @type {import('#types').Migration[]}
20
+ */
21
+ export const migrations = [addonBlueprint, classicAddon, appBlueprint, classicApp];
22
+
23
+ /**
24
+ * @param {string} directory an existing project
25
+ * @returns {import('#types').Migration | undefined}
26
+ */
27
+ export function detectMigration(directory) {
28
+ return migrations.find((migration) => migration.detect(directory));
29
+ }
30
+
31
+ /**
32
+ * Checks the project, and changes nothing.
33
+ *
34
+ * @param {import('#utils/project.js').Project} project
35
+ * @returns {Promise<{ migration: import('#types').Migration, report: import('#types').MigrationReport } | undefined>}
36
+ * undefined when the project is not from an older blueprint
37
+ */
38
+ export async function checkMigration(project) {
39
+ let migration = detectMigration(project.directory);
40
+
41
+ if (!migration) return;
42
+
43
+ let report = await migration.check(project);
44
+
45
+ if (migration.type !== project.type) {
46
+ report.unsupported.unshift({
47
+ title: `A ${migration.type} from ${migration.label}`,
48
+ where: [`selected type: ${project.type}`],
49
+ action: `Select "${migration.type}" as the project type.`,
50
+ });
51
+ }
52
+
53
+ return { migration, report };
54
+ }
55
+
56
+ /**
57
+ * Moves a project from an older blueprint to the ember.nvp base,
58
+ * so that the base and the layers can run over it.
59
+ *
60
+ * Throws a MigrationError, before any change,
61
+ * when the project uses features that ember.nvp does not support.
62
+ *
63
+ * @param {import('#utils/project.js').Project} project
64
+ * @returns {Promise<{ migration: import('#types').Migration, report: import('#types').MigrationReport } | undefined>}
65
+ * undefined when the project is not from an older blueprint
66
+ */
67
+ export async function migrate(project) {
68
+ let result = await checkMigration(project);
69
+
70
+ if (!result) return;
71
+
72
+ let { migration, report } = result;
73
+
74
+ if (report.unsupported.length > 0) {
75
+ throw new MigrationError(migration.label, report.unsupported);
76
+ }
77
+
78
+ await migration.run(project, report);
79
+
80
+ return result;
81
+ }
@@ -0,0 +1,217 @@
1
+ import { parse } from "ember-estree";
2
+
3
+ /**
4
+ * An ESTree node.
5
+ *
6
+ * ember-estree types every property as `unknown`,
7
+ * and these helpers read many shapes of config files.
8
+ *
9
+ * @typedef {{ type: string, start?: number, end?: number, [key: string]: any }} Node
10
+ */
11
+
12
+ /**
13
+ * @param {string} source
14
+ * @param {string} filePath decides the parser: `.ts` parses TypeScript
15
+ * @returns {Node | undefined} the Program, or undefined when the file does not parse
16
+ */
17
+ export function parseProgram(source, filePath) {
18
+ try {
19
+ let file = /** @type {Node} */ (parse(source, { filePath }));
20
+
21
+ return /** @type {Node} */ (file.program ?? file);
22
+ } catch {
23
+ return undefined;
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Calls `visit` for every node below `node`, parents first.
29
+ *
30
+ * @param {unknown} node
31
+ * @param {(node: Node, parent: Node | undefined) => void} visit
32
+ * @param {Node} [parent]
33
+ */
34
+ export function walk(node, visit, parent) {
35
+ if (!node || typeof node !== "object") return;
36
+
37
+ if (Array.isArray(node)) {
38
+ for (let child of node) walk(child, visit, parent);
39
+ return;
40
+ }
41
+
42
+ let astNode = /** @type {Node} */ (node);
43
+
44
+ if (typeof astNode.type !== "string") return;
45
+
46
+ visit(astNode, parent);
47
+
48
+ for (let [key, value] of Object.entries(astNode)) {
49
+ if (key === "loc" || key === "range" || key === "parent") continue;
50
+
51
+ walk(value, visit, astNode);
52
+ }
53
+ }
54
+
55
+ /**
56
+ * @param {unknown} node
57
+ * @returns {string | undefined} the value of a string literal, or of a template literal without expressions
58
+ */
59
+ export function stringValue(node) {
60
+ let astNode = /** @type {Node | undefined} */ (node);
61
+
62
+ if (!astNode) return;
63
+
64
+ if (astNode.type === "Literal" && typeof astNode.value === "string") {
65
+ return astNode.value;
66
+ }
67
+
68
+ if (astNode.type === "StringLiteral") {
69
+ return /** @type {string} */ (astNode.value);
70
+ }
71
+
72
+ if (astNode.type === "TemplateLiteral" && astNode.expressions?.length === 0) {
73
+ return astNode.quasis?.[0]?.value?.cooked;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * @param {unknown} node
79
+ * @returns {string[] | undefined} the values of an array of string literals
80
+ */
81
+ export function stringArray(node) {
82
+ let astNode = /** @type {Node | undefined} */ (node);
83
+
84
+ if (astNode?.type !== "ArrayExpression") return;
85
+
86
+ /** @type {string[]} */
87
+ let result = [];
88
+
89
+ for (let element of astNode.elements ?? []) {
90
+ let value = stringValue(element);
91
+
92
+ if (value === undefined) return;
93
+
94
+ result.push(value);
95
+ }
96
+
97
+ return result;
98
+ }
99
+
100
+ /**
101
+ * @param {Node | undefined} node an ObjectExpression
102
+ * @param {string} name
103
+ * @returns {Node | undefined} the value of the property named `name`
104
+ */
105
+ export function propertyValue(node, name) {
106
+ if (node?.type !== "ObjectExpression") return;
107
+
108
+ for (let property of node.properties ?? []) {
109
+ if (property.type !== "Property" && property.type !== "ObjectProperty") continue;
110
+
111
+ let key = property.key;
112
+ let keyName = key?.type === "Identifier" ? key.name : stringValue(key);
113
+
114
+ if (keyName === name) return property.value;
115
+ }
116
+ }
117
+
118
+ /**
119
+ * @param {Node | undefined} node an ObjectExpression
120
+ * @returns {string[]} the names of its properties, with `...` for spreads and computed keys
121
+ */
122
+ export function propertyNames(node) {
123
+ if (node?.type !== "ObjectExpression") return [];
124
+
125
+ /** @type {string[]} */
126
+ let names = [];
127
+
128
+ for (let property of node.properties ?? []) {
129
+ let key = property.key;
130
+ let keyName = key?.type === "Identifier" && !property.computed ? key.name : stringValue(key);
131
+
132
+ names.push(keyName ?? "...");
133
+ }
134
+
135
+ return names;
136
+ }
137
+
138
+ /**
139
+ * Maps each imported (or required) local name to the module it comes from.
140
+ *
141
+ * import { babel } from '@rollup/plugin-babel'; babel → @rollup/plugin-babel
142
+ * const EmberApp = require('ember-cli/lib/broccoli/ember-app');
143
+ *
144
+ * @param {Node} program
145
+ * @returns {Map<string, string>}
146
+ */
147
+ export function importedNames(program) {
148
+ /** @type {Map<string, string>} */
149
+ let names = new Map();
150
+
151
+ walk(program, (node) => {
152
+ if (node.type === "ImportDeclaration") {
153
+ let source = stringValue(node.source);
154
+
155
+ if (!source) return;
156
+
157
+ for (let specifier of node.specifiers ?? []) {
158
+ names.set(specifier.local.name, source);
159
+ }
160
+
161
+ return;
162
+ }
163
+
164
+ if (node.type === "VariableDeclarator" && node.init) {
165
+ let source = requiredModule(node.init);
166
+
167
+ if (!source) return;
168
+
169
+ if (node.id?.type === "Identifier") {
170
+ names.set(node.id.name, source);
171
+ }
172
+
173
+ if (node.id?.type === "ObjectPattern") {
174
+ for (let property of node.id.properties ?? []) {
175
+ if (property.value?.type === "Identifier") {
176
+ names.set(property.value.name, source);
177
+ }
178
+ }
179
+ }
180
+ }
181
+ });
182
+
183
+ return names;
184
+ }
185
+
186
+ /**
187
+ * @param {Node} node
188
+ * @returns {string | undefined} `x` for `require('x')`, `require('x').y`, and `await import('x')`
189
+ */
190
+ function requiredModule(node) {
191
+ let current = node;
192
+
193
+ while (current?.type === "MemberExpression" || current?.type === "AwaitExpression") {
194
+ current = current.type === "AwaitExpression" ? current.argument : current.object;
195
+ }
196
+
197
+ if (current?.type === "ImportExpression") {
198
+ return stringValue(current.source);
199
+ }
200
+
201
+ if (
202
+ current?.type === "CallExpression" &&
203
+ current.callee?.type === "Identifier" &&
204
+ current.callee.name === "require"
205
+ ) {
206
+ return stringValue(current.arguments?.[0]);
207
+ }
208
+ }
209
+
210
+ /**
211
+ * @param {string} source
212
+ * @param {Node} node
213
+ * @returns {string} the node's source text
214
+ */
215
+ export function sourceOf(source, node) {
216
+ return source.slice(/** @type {number} */ (node.start), /** @type {number} */ (node.end));
217
+ }
@@ -0,0 +1,170 @@
1
+ import { packageJson } from "ember-apply";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { remove } from "./files.js";
5
+
6
+ /**
7
+ * Tooling that the old blueprints generate, by the layer that replaces it.
8
+ *
9
+ * When the user selects the layer, the migration removes the old tooling,
10
+ * so that the layer can write its own.
11
+ * Otherwise the old tooling stays as it is.
12
+ *
13
+ * @type {Record<string, { files: string[], scripts: string[], devDependencies: string[] }>}
14
+ */
15
+ const REPLACED = {
16
+ "eslint-bundled": {
17
+ files: [
18
+ "eslint.config.mjs",
19
+ "eslint.config.cjs",
20
+ "eslint.config.js",
21
+ ".eslintrc.js",
22
+ ".eslintrc.cjs",
23
+ ".eslintrc.json",
24
+ ".eslintrc",
25
+ ".eslintignore",
26
+ ],
27
+ scripts: ["lint:js", "lint:js:fix"],
28
+ devDependencies: [
29
+ "@babel/eslint-parser",
30
+ "@eslint/js",
31
+ "@typescript-eslint/eslint-plugin",
32
+ "@typescript-eslint/parser",
33
+ "eslint-config-prettier",
34
+ "eslint-plugin-decorator-position",
35
+ "eslint-plugin-ember",
36
+ "eslint-plugin-import",
37
+ "eslint-plugin-n",
38
+ "eslint-plugin-node",
39
+ "eslint-plugin-prettier",
40
+ "eslint-plugin-qunit",
41
+ "eslint-plugin-warp-drive",
42
+ "globals",
43
+ "typescript-eslint",
44
+ ],
45
+ },
46
+ prettier: {
47
+ files: [
48
+ ".prettierrc.mjs",
49
+ ".prettierrc.cjs",
50
+ ".prettierrc.js",
51
+ ".prettierrc.json",
52
+ ".prettierrc",
53
+ ],
54
+ scripts: ["lint:format"],
55
+ devDependencies: [],
56
+ },
57
+ "github-actions": {
58
+ files: [".github/workflows/ci.yml"],
59
+ scripts: [],
60
+ devDependencies: [],
61
+ },
62
+ // Glint's own CLI and environments, which the typescript layer's setup does not use
63
+ typescript: {
64
+ files: [],
65
+ scripts: [],
66
+ devDependencies: [
67
+ "@glint/core",
68
+ "@glint/environment-ember-loose",
69
+ "@glint/environment-ember-template-imports",
70
+ ],
71
+ },
72
+ };
73
+
74
+ /**
75
+ * @param {import('#utils/project.js').Project} project
76
+ * @param {import('#types').MigrationReport} report
77
+ */
78
+ export async function removeToolingOfWantedLayers(project, report) {
79
+ for (let [layer, replaced] of Object.entries(REPLACED)) {
80
+ if (!project.wantsLayer(layer)) continue;
81
+
82
+ await remove(project, replaced.files);
83
+ await packageJson.removeDevDependencies(replaced.devDependencies, project.directory);
84
+ await packageJson.modify((json) => {
85
+ for (let script of replaced.scripts) {
86
+ delete json.scripts?.[script];
87
+ }
88
+ }, project.directory);
89
+ }
90
+
91
+ if (!project.wantsLayer("typescript")) return;
92
+
93
+ let manifest = await packageJson.read(project.directory);
94
+ /** @type {string[]} */
95
+ let glint = [];
96
+
97
+ for (let [name, command] of Object.entries(manifest.scripts ?? {})) {
98
+ // the typescript layer writes its own lint:types
99
+ if (name !== "lint:types" && /(^|\s|&&)glint\b/.test(String(command)))
100
+ glint.push(`${name}: ${command}`);
101
+ }
102
+
103
+ if (glint.length > 0) {
104
+ report.todo.push({
105
+ title: "Scripts that run the glint CLI",
106
+ where: glint,
107
+ action:
108
+ "Run the checker of the typescript layer instead, the way lint:types does.\n" +
109
+ "The migration removed @glint/core, which has the glint CLI.",
110
+ });
111
+ }
112
+ }
113
+
114
+ /**
115
+ * The testem config that the qunit layer writes, by project kind
116
+ */
117
+ const QUNIT_TESTEM = {
118
+ app: join(import.meta.dirname, "../layers/qunit/files/testem.cjs"),
119
+ library: join(import.meta.dirname, "../layers/qunit/library-files/config/test/testem.cjs"),
120
+ };
121
+
122
+ /**
123
+ * Browser flags are what projects change in a testem config,
124
+ * for example to let Chrome render WebGL.
125
+ *
126
+ * @param {string} root
127
+ * @param {string} file the old testem config, which the qunit layer's config replaces
128
+ * @param {"app" | "library"} kind
129
+ * @returns {import('#types').Finding | undefined} undefined when both configs pass the same flags
130
+ */
131
+ export function changedTestemFlags(root, file, kind) {
132
+ if (!existsSync(join(root, file))) return;
133
+
134
+ let old = browserFlags(readFileSync(join(root, file), "utf-8"));
135
+ let current = browserFlags(readFileSync(QUNIT_TESTEM[kind], "utf-8"));
136
+ let target = kind === "library" ? "config/test/testem.cjs" : "testem.cjs";
137
+
138
+ /** @type {string[]} */
139
+ let where = [];
140
+
141
+ for (let flag of old) {
142
+ if (!current.has(flag)) where.push(`${flag}: only in ${file}`);
143
+ }
144
+
145
+ for (let flag of current) {
146
+ if (!old.has(flag)) where.push(`${flag}: only in ${target}`);
147
+ }
148
+
149
+ if (where.length === 0) return;
150
+
151
+ return {
152
+ title: "Browser flags that differ in the new testem config",
153
+ where,
154
+ action: `Change the flags in ${target} to the ones that your tests need.`,
155
+ };
156
+ }
157
+
158
+ /**
159
+ * @param {string} source
160
+ * @returns {Set<string>} such as `--headless`, with `--headless=new` read as `--headless`
161
+ */
162
+ function browserFlags(source) {
163
+ let flags = new Set();
164
+
165
+ for (let [, flag] of source.matchAll(/["'`](--[\w-]+(?:=[^"'`]*)?)["'`]/g)) {
166
+ flags.add(flag === "--headless=new" ? "--headless" : flag);
167
+ }
168
+
169
+ return flags;
170
+ }