ember.nvp 1.14.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,1117 @@
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 { readJSON } from "#utils/fs.js";
7
+ import { getLatest } from "#utils/npm.js";
8
+ import { appModulesIn, installedDependencies, v1AddonsIn } from "./addons.js";
9
+ import { readBabelConfig } from "./babel.js";
10
+ import {
11
+ EMBER_CLI_BUILD,
12
+ readEmberCliBuild,
13
+ readEnvironmentKeys,
14
+ unsupportedIn,
15
+ } from "./ember-cli-build.js";
16
+ import { filesWithExtension, firstExisting, listFiles, remove, testsWithHbs } from "./files.js";
17
+ import {
18
+ importedNames,
19
+ parseProgram,
20
+ propertyNames,
21
+ propertyValue,
22
+ sourceOf,
23
+ walk,
24
+ } from "./javascript.js";
25
+ import { removeToolingOfWantedLayers } from "./layers.js";
26
+
27
+ /**
28
+ * @typedef {import('#types').Finding} Finding
29
+ * @typedef {import('#types').MigrationReport} MigrationReport
30
+ * @typedef {import('#utils/project.js').Project} Project
31
+ */
32
+
33
+ const BASE_FILES = join(import.meta.dirname, "../bases/minimal-app/files");
34
+
35
+ /**
36
+ * What the ember.nvp app base builds with.
37
+ * Read from the base, so that a migrated app matches a generated one.
38
+ */
39
+ const BASE_MANIFEST = readJSON(join(BASE_FILES, "package.json"));
40
+
41
+ /**
42
+ * The app base boots with the strict resolver (RFC 1132),
43
+ * which ember-source has from 7.2 on.
44
+ */
45
+ const MINIMUM_EMBER_SOURCE = "7.2.0";
46
+
47
+ /**
48
+ * The ember-cli and compat toolchain.
49
+ * ember.nvp apps build without these, so the migration removes them.
50
+ */
51
+ export const TOOLCHAIN = new Set([
52
+ "@ember/optional-features",
53
+ "@embroider/compat",
54
+ "@embroider/config-meta-loader",
55
+ "@embroider/legacy-inspector-support",
56
+ "@embroider/test-setup",
57
+ "@embroider/vite",
58
+ "@embroider/webpack",
59
+ "@glimmer/tracking",
60
+ "broccoli-asset-rev",
61
+ "ember-auto-import",
62
+ "ember-cli",
63
+ "ember-cli-babel",
64
+ "ember-cli-clean-css",
65
+ "ember-cli-dependency-checker",
66
+ "ember-cli-htmlbars",
67
+ "ember-cli-inject-live-reload",
68
+ "ember-cli-sri",
69
+ "ember-cli-terser",
70
+ "ember-cli-typescript",
71
+ "ember-cli-uglify",
72
+ "ember-disable-prototype-extensions",
73
+ "ember-export-application-global",
74
+ "ember-maybe-import-regenerator",
75
+ "ember-resolver",
76
+ "ember-source-channel-url",
77
+ "ember-template-imports",
78
+ "ember-try",
79
+ "loader.js",
80
+ "webpack",
81
+ ]);
82
+
83
+ /**
84
+ * v1 addons that the blueprints add, which do nothing unless the app uses them
85
+ *
86
+ * @type {Record<string, RegExp>}
87
+ */
88
+ const UNUSED_BLUEPRINT_ADDONS = {
89
+ "ember-cli-app-version": /app-version|appVersion|ember-cli-app-version/,
90
+ };
91
+
92
+ /**
93
+ * @param {string} root
94
+ * @returns {Set<string>} the dependencies that the migration removes
95
+ */
96
+ function removedDependencies(root) {
97
+ let removed = new Set(TOOLCHAIN);
98
+ let files = filesWithExtension(root, ["app", "tests"], [".js", ".ts", ".gjs", ".gts", ".hbs"]);
99
+ let sources = files.map((file) => readFileSync(join(root, file), "utf-8"));
100
+
101
+ for (let [name, usage] of Object.entries(UNUSED_BLUEPRINT_ADDONS)) {
102
+ if (!sources.some((source) => usage.test(source))) removed.add(name);
103
+ }
104
+
105
+ return removed;
106
+ }
107
+
108
+ /**
109
+ * Settings in config/environment.js that the base's app/config.ts covers,
110
+ * or that only the ember-cli build reads.
111
+ */
112
+ const KNOWN_ENVIRONMENT = new Set([
113
+ "APP",
114
+ "APP.LOG_ACTIVE_GENERATION",
115
+ "APP.LOG_RESOLVER",
116
+ "APP.LOG_TRANSITIONS",
117
+ "APP.LOG_TRANSITIONS_INTERNAL",
118
+ "APP.LOG_VIEW_LOOKUPS",
119
+ "APP.autoboot",
120
+ "APP.rootElement",
121
+ "EmberENV",
122
+ "EmberENV.EXTEND_PROTOTYPES",
123
+ "EmberENV.EXTEND_PROTOTYPES.Date",
124
+ "EmberENV.FEATURES",
125
+ "EmberENV._APPLICATION_TEMPLATE_WRAPPER",
126
+ "EmberENV._DEFAULT_ASYNC_OBSERVERS",
127
+ "EmberENV._JQUERY_INTEGRATION",
128
+ "EmberENV._NO_IMPLICIT_ROUTE_MODEL",
129
+ "EmberENV._TEMPLATE_ONLY_GLIMMER_COMPONENTS",
130
+ "environment",
131
+ "locationType",
132
+ "modulePrefix",
133
+ "podModulePrefix",
134
+ "rootURL",
135
+ ]);
136
+
137
+ /**
138
+ * Ember's defaults, which ember.nvp apps use
139
+ */
140
+ const OPTIONAL_FEATURES = {
141
+ "application-template-wrapper": false,
142
+ "default-async-observers": true,
143
+ "jquery-integration": false,
144
+ "template-only-glimmer-components": true,
145
+ "no-implicit-route-model": true,
146
+ };
147
+
148
+ /**
149
+ * Folders under app/ that the app looks up by name at runtime.
150
+ * The base registers routes, templates, and services.
151
+ * The migration registers these too, when the app has them.
152
+ */
153
+ const REGISTERED_FOLDERS = ["controllers", "models", "adapters", "serializers", "transforms"];
154
+
155
+ /**
156
+ * Babel plugins that the base's babel.config.js has, or that only the compat build needs
157
+ */
158
+ const BASE_BABEL_PLUGINS = new Set([
159
+ "@babel/plugin-transform-runtime",
160
+ "@babel/plugin-transform-typescript",
161
+ "@babel/plugin-proposal-decorators",
162
+ "@babel/plugin-transform-class-static-block",
163
+ "babel-plugin-debug-macros",
164
+ "babel-plugin-ember-template-compilation",
165
+ "module:decorator-transforms",
166
+ ]);
167
+
168
+ /**
169
+ * Checks what every app needs, from either app blueprint.
170
+ *
171
+ * @param {Project} project
172
+ * @returns {Promise<MigrationReport>}
173
+ */
174
+ export async function checkApp(project) {
175
+ /** @type {MigrationReport} */
176
+ let report = { unsupported: [], todo: [], layers: [] };
177
+ let root = project.directory;
178
+ let manifest = await packageJson.read(root);
179
+ /** @type {Record<string, string>} */
180
+ let dependencies = { ...manifest.dependencies, ...manifest.devDependencies };
181
+
182
+ checkEmberSource(dependencies, report);
183
+
184
+ let removed = removedDependencies(root);
185
+ /** @type {Record<string, string>} */
186
+ let toCheck = {};
187
+
188
+ for (let [name, range] of Object.entries(dependencies)) {
189
+ // checked on its own, above
190
+ if (name === "ember-source") continue;
191
+ if (!removed.has(name)) toCheck[name] = range;
192
+ }
193
+
194
+ let v1 = v1AddonsIn(installedDependencies(project.desires.path, toCheck));
195
+
196
+ if (v1.length > 0) {
197
+ report.unsupported.push({
198
+ title: "v1 addons",
199
+ where: v1,
200
+ action:
201
+ "Upgrade each addon to a v2 version, or remove it.\n" +
202
+ "ember.nvp builds without ember-cli, so v1 addons do not load.",
203
+ });
204
+ }
205
+
206
+ let templates = filesWithExtension(root, ["app"], [".hbs"]);
207
+
208
+ if (templates.length > 0) {
209
+ report.unsupported.push({
210
+ title: "Templates in .hbs files",
211
+ where: templates,
212
+ action:
213
+ "Convert them to <template> in .gjs or .gts files: npx @embroider/template-tag-codemod\n" +
214
+ "ember.nvp apps build without @embroider/compat, which is what compiles .hbs files.",
215
+ });
216
+ }
217
+
218
+ let pods = podsIn(root);
219
+
220
+ if (pods.length > 0) {
221
+ report.unsupported.push({
222
+ title: "The pods layout",
223
+ where: pods,
224
+ action:
225
+ "Move each module to the folder of its type, such as app/components/ and app/routes/.",
226
+ });
227
+ }
228
+
229
+ let buildFile = firstExisting(root, EMBER_CLI_BUILD);
230
+ let build = buildFile ? readEmberCliBuild(root, buildFile) : undefined;
231
+ let fromBuild = build ? unsupportedIn(build) : [];
232
+
233
+ if (buildFile && !build) {
234
+ fromBuild.push(`${buildFile}: ember.nvp cannot read this file`);
235
+ }
236
+
237
+ if (fromBuild.length > 0) {
238
+ report.unsupported.push({
239
+ title: "Build settings in ember-cli-build",
240
+ where: fromBuild,
241
+ action:
242
+ "Remove each setting, or replace it with an import in the app's code.\n" +
243
+ "ember.nvp builds with Vite alone, and does not read ember-cli-build.",
244
+ });
245
+ }
246
+
247
+ let inRepoAddons = manifest["ember-addon"]?.paths ?? [];
248
+
249
+ if (inRepoAddons.length > 0) {
250
+ report.unsupported.push({
251
+ title: "In-repo addons",
252
+ where: inRepoAddons,
253
+ action:
254
+ "Move each one into app/, or into its own v2 addon package.\n" +
255
+ "In-repo addons are v1 addons, which do not load without ember-cli.",
256
+ });
257
+ }
258
+
259
+ let vendor = listFiles(root, "vendor").filter((file) => !file.endsWith(".gitkeep"));
260
+
261
+ if (vendor.length > 0) {
262
+ report.unsupported.push({
263
+ title: "Files in vendor/",
264
+ where: vendor,
265
+ action: "Import each file from the app's code, or install it from npm.",
266
+ });
267
+ }
268
+
269
+ let features = changedOptionalFeatures(root);
270
+
271
+ if (features.length > 0) {
272
+ report.unsupported.push({
273
+ title: "Optional features that differ from Ember's defaults",
274
+ where: features,
275
+ action: "Change the app to work with the default of each feature.",
276
+ });
277
+ }
278
+
279
+ let styles = listFiles(root, "app/styles").filter((file) =>
280
+ /\.(scss|sass|less|styl)$/.test(file),
281
+ );
282
+
283
+ if (styles.length > 0) {
284
+ report.unsupported.push({
285
+ title: "Styles that an ember-cli addon compiles",
286
+ where: styles,
287
+ action:
288
+ "Convert them to CSS.\n" +
289
+ "After the migration, Vite can compile Sass and Less again, when you install the compiler.",
290
+ });
291
+ }
292
+
293
+ let environment = customEnvironment(root);
294
+
295
+ if (environment.length > 0) {
296
+ report.todo.push({
297
+ title: "Settings in config/environment.js",
298
+ where: environment,
299
+ action:
300
+ "Add the settings that you still need to app/config.ts.\n" +
301
+ "Values from process.env move to import.meta.env, with a VITE_ prefix.",
302
+ });
303
+ }
304
+
305
+ let appModules = addonModules(project, toCheck);
306
+ let initializers = appModules.filter((module) => /^(instance-)?initializers\//.test(module.path));
307
+
308
+ if (initializers.length > 0) {
309
+ report.todo.push({
310
+ title: "Initializers from addons",
311
+ where: initializers.map((module) => `${module.addon}: ${module.path}`),
312
+ action:
313
+ "Run each one from app/app.ts, with the addon's documented setup.\n" +
314
+ "ember.nvp apps do not merge addon modules into the app.",
315
+ });
316
+ }
317
+
318
+ let unregistered = addonServices(root, appModules).filter((module) => !module.specifier);
319
+
320
+ if (unregistered.length > 0 || !hasInstall(project)) {
321
+ report.todo.push({
322
+ title: "Services from addons",
323
+ where: unregistered.map((module) => `${module.addon}: ${module.path}`),
324
+ action:
325
+ "Register each addon service that the app injects, in `modules` in app/app.ts:\n" +
326
+ ` "./services/name": ServiceFromTheAddon,` +
327
+ (hasInstall(project) ? "" : "\nember.nvp lists them when the project has node_modules."),
328
+ });
329
+ }
330
+
331
+ let hbsTests = testsWithHbs(root, ["tests"]);
332
+
333
+ if (hbsTests.length > 0) {
334
+ report.todo.push({
335
+ title: "Tests that render hbs templates",
336
+ where: hbsTests,
337
+ action:
338
+ "Convert each test to .gjs or .gts, and render with <template>.\n" +
339
+ "The hbs template literal needs ember-cli.",
340
+ });
341
+ }
342
+
343
+ let testSetup = customTestHelper(root);
344
+
345
+ if (testSetup.length > 0) {
346
+ report.todo.push({
347
+ title: "Test setup of your own",
348
+ where: testSetup,
349
+ action: "Add the setup that you still need to the qunit layer's tests/test-helper.ts.",
350
+ });
351
+ }
352
+
353
+ if (listFiles(root, "tests").some((file) => /-test\.(js|ts|gjs|gts)$/.test(file))) {
354
+ report.layers.push("qunit");
355
+ }
356
+
357
+ if (existsSync(join(root, "tsconfig.json"))) {
358
+ report.layers.push("typescript");
359
+ }
360
+
361
+ if (dependencies["@embroider/legacy-inspector-support"]) {
362
+ report.layers.push("inspector-support");
363
+ }
364
+
365
+ return report;
366
+ }
367
+
368
+ /**
369
+ * Modules that the blueprints' tests/test-helper imports
370
+ */
371
+ const BLUEPRINT_TEST_HELPER_IMPORTS =
372
+ /^(@ember\/test-helpers|@embroider\/macros|@warp-drive\/ember\/install|ember-qunit|qunit|qunit-dom|.*\/app|.*\/config\/environment)$/;
373
+
374
+ /**
375
+ * @param {string} root
376
+ * @returns {string[]} imports of tests/test-helper beyond the blueprints'
377
+ */
378
+ function customTestHelper(root) {
379
+ let file = firstExisting(root, ["tests/test-helper.ts", "tests/test-helper.js"]);
380
+
381
+ if (!file) return [];
382
+
383
+ let program = parseProgram(readFileSync(join(root, file), "utf-8"), file);
384
+
385
+ if (!program) return [`${file}: ember.nvp cannot read this file`];
386
+
387
+ /** @type {string[]} */
388
+ let custom = [];
389
+
390
+ for (let module of new Set(importedNames(program).values())) {
391
+ if (!BLUEPRINT_TEST_HELPER_IMPORTS.test(module)) custom.push(`${file}: import from ${module}`);
392
+ }
393
+
394
+ return custom;
395
+ }
396
+
397
+ /**
398
+ * Modules of the addons that the blueprints add to every app.
399
+ * Without an install, these are the only addon modules that the migration knows.
400
+ *
401
+ * @type {import('./addons.js').AppModule[]}
402
+ */
403
+ const BLUEPRINT_ADDON_MODULES = [
404
+ {
405
+ addon: "ember-page-title",
406
+ path: "services/page-title",
407
+ specifier: "ember-page-title/services/page-title",
408
+ },
409
+ ];
410
+
411
+ /**
412
+ * @param {Project} project
413
+ */
414
+ function hasInstall(project) {
415
+ return existsSync(join(project.desires.path, "node_modules"));
416
+ }
417
+
418
+ /**
419
+ * @param {Project} project
420
+ * @param {Record<string, string>} dependencies
421
+ * @returns {import('./addons.js').AppModule[]} the modules that the app's addons merge into it
422
+ */
423
+ function addonModules(project, dependencies) {
424
+ if (hasInstall(project)) {
425
+ return appModulesIn(installedDependencies(project.desires.path, dependencies));
426
+ }
427
+
428
+ return BLUEPRINT_ADDON_MODULES.filter((module) => module.addon in dependencies);
429
+ }
430
+
431
+ /**
432
+ * @param {string} root
433
+ * @param {import('./addons.js').AppModule[]} modules
434
+ * @returns {import('./addons.js').AppModule[]} the services from addons that the app does not define itself
435
+ */
436
+ function addonServices(root, modules) {
437
+ let own = new Set(
438
+ listFiles(root, "app/services").map((file) => file.replace(/^app\//, "").replace(/\.\w+$/, "")),
439
+ );
440
+
441
+ return modules.filter((module) => module.path.startsWith("services/") && !own.has(module.path));
442
+ }
443
+
444
+ /**
445
+ * @param {Record<string, string>} dependencies
446
+ * @param {MigrationReport} report
447
+ */
448
+ function checkEmberSource(dependencies, report) {
449
+ let range = dependencies["ember-source"];
450
+ let version = range && semver.validRange(range) ? semver.minVersion(range)?.version : undefined;
451
+
452
+ if (!version || semver.gte(version, MINIMUM_EMBER_SOURCE)) return;
453
+
454
+ report.unsupported.push({
455
+ title: "An ember-source older than 7.2",
456
+ where: [`ember-source@${version}`],
457
+ action:
458
+ "Upgrade ember-source to 7.2 or later.\n" +
459
+ "ember.nvp apps boot with the strict resolver, which ember-source has from 7.2 on.",
460
+ });
461
+ }
462
+
463
+ /**
464
+ * @param {string} root
465
+ * @returns {string[]} where the app says that it uses pods
466
+ */
467
+ function podsIn(root) {
468
+ /** @type {string[]} */
469
+ let found = [];
470
+ let emberCli = join(root, ".ember-cli");
471
+
472
+ if (existsSync(emberCli) && /"usePods"\s*:\s*true/.test(readFileSync(emberCli, "utf-8"))) {
473
+ found.push(".ember-cli: usePods");
474
+ }
475
+
476
+ let environment = firstExisting(root, ["config/environment.js", "config/environment.cjs"]);
477
+
478
+ if (environment) {
479
+ let source = readFileSync(join(root, environment), "utf-8");
480
+
481
+ if (/podModulePrefix\s*:\s*["'`][^"'`]+["'`]/.test(source)) {
482
+ found.push(`${environment}: podModulePrefix`);
483
+ }
484
+ }
485
+
486
+ return found;
487
+ }
488
+
489
+ /**
490
+ * @param {string} root
491
+ * @returns {string[]} such as `jquery-integration: true`
492
+ */
493
+ function changedOptionalFeatures(root) {
494
+ let features = readJSON(join(root, "config/optional-features.json")) ?? {};
495
+
496
+ /** @type {string[]} */
497
+ let changed = [];
498
+
499
+ for (let [feature, value] of Object.entries(features)) {
500
+ let expected = /** @type {Record<string, boolean>} */ (OPTIONAL_FEATURES)[feature];
501
+
502
+ if (expected !== undefined && expected !== value) {
503
+ changed.push(`config/optional-features.json: ${feature}: ${value}`);
504
+ }
505
+ }
506
+
507
+ return changed;
508
+ }
509
+
510
+ /**
511
+ * @param {string} root
512
+ * @returns {string[]} settings that the base's app/config.ts does not have
513
+ */
514
+ function customEnvironment(root) {
515
+ let file = firstExisting(root, ["config/environment.js", "config/environment.cjs"]);
516
+
517
+ if (!file) return [];
518
+
519
+ let keys = readEnvironmentKeys(root, file);
520
+
521
+ if (!keys) return [`${file}: ember.nvp cannot read this file`];
522
+
523
+ /** @type {string[]} */
524
+ let custom = [];
525
+
526
+ for (let key of keys) {
527
+ if (KNOWN_ENVIRONMENT.has(key)) continue;
528
+ // only the parent is interesting
529
+ if (custom.some((parent) => key.startsWith(`${parent}.`))) continue;
530
+
531
+ custom.push(key);
532
+ }
533
+
534
+ return custom.map((key) => `ENV.${key}`);
535
+ }
536
+
537
+ /**
538
+ * @typedef {object} AppMigration
539
+ * @property {string} indexHtml where the old index.html is
540
+ */
541
+
542
+ /**
543
+ * Replaces the ember-cli and compat setup with the app base's.
544
+ *
545
+ * @param {Project} project
546
+ * @param {MigrationReport} report
547
+ * @param {AppMigration} options
548
+ */
549
+ export async function migrateApp(project, report, { indexHtml }) {
550
+ let root = project.directory;
551
+ let manifest = await packageJson.read(root);
552
+ let modulePrefix = readModulePrefix(root) ?? manifest.name;
553
+ let typescript = await project.hasOrWantsLayer("typescript");
554
+ let buildFile = firstExisting(root, EMBER_CLI_BUILD);
555
+ let build = buildFile ? readEmberCliBuild(root, buildFile) : undefined;
556
+ let appModules = addonModules(project, {
557
+ ...manifest.dependencies,
558
+ ...manifest.devDependencies,
559
+ });
560
+
561
+ await removeToolingOfWantedLayers(project, report);
562
+
563
+ await writeFile(
564
+ project.path("index.html"),
565
+ rewriteIndexHtml(project, indexHtml, [modulePrefix, manifest.name], report),
566
+ );
567
+
568
+ if (indexHtml !== "index.html") {
569
+ await remove(project, [indexHtml]);
570
+ }
571
+
572
+ let initializers =
573
+ listFiles(root, "app/initializers").length +
574
+ listFiles(root, "app/instance-initializers").length >
575
+ 0;
576
+
577
+ await writeAppModule(project, report, {
578
+ typescript,
579
+ services: addonServices(root, appModules).filter((module) => module.specifier),
580
+ initializers,
581
+ });
582
+
583
+ await replaceBabelConfig(project, report, build?.warpDrive);
584
+ await replaceViteConfig(project, report);
585
+ await rewriteImports(project, modulePrefix, manifest.name);
586
+
587
+ await remove(project, EMBER_CLI_BUILD);
588
+ await remove(project, [
589
+ ".ember-cli",
590
+ ".watchmanconfig",
591
+ "app/config/environment.d.ts",
592
+ "app/config/environment.js",
593
+ "app/config/environment.ts",
594
+ "config/ember-cli-update.json",
595
+ "config/environment.js",
596
+ "config/optional-features.json",
597
+ "config/targets.js",
598
+ // the qunit layer brings its own test setup. testem.cjs already works with it.
599
+ "testem.js",
600
+ "tests/index.html",
601
+ "tests/test-helper.js",
602
+ "tests/test-helper.ts",
603
+ ]);
604
+
605
+ if (listFiles(root, "app/config").length === 0) {
606
+ await remove(project, ["app/config"]);
607
+ }
608
+
609
+ if (
610
+ listFiles(root, "tests").some((file) => /-test\.(js|ts|gjs|gts)$/.test(file)) &&
611
+ !project.wantsLayer("qunit")
612
+ ) {
613
+ report.todo.push({
614
+ title: "Tests without a test setup",
615
+ where: ["tests/"],
616
+ action: "Run ember.nvp again with the qunit layer, or delete the tests.",
617
+ });
618
+ }
619
+
620
+ await usePackageJsonOfBase(project, { initializers });
621
+ }
622
+
623
+ /**
624
+ * @param {string} root
625
+ * @returns {string | undefined}
626
+ */
627
+ function readModulePrefix(root) {
628
+ let file = firstExisting(root, ["config/environment.js", "config/environment.cjs"]);
629
+
630
+ if (!file) return;
631
+
632
+ let match = /modulePrefix\s*:\s*["'`]([^"'`]+)["'`]/.exec(
633
+ readFileSync(join(root, file), "utf-8"),
634
+ );
635
+
636
+ return match?.[1];
637
+ }
638
+
639
+ /**
640
+ * The base's index.html boots the app from `#app/app`, with the config from `#config`.
641
+ *
642
+ * The rest of the old index.html stays, except:
643
+ * - ember-cli's `{{content-for}}` placeholders
644
+ * - the vendor files and boot script of the old build
645
+ * - the app's stylesheet, which Vite serves from app/styles/app.css
646
+ *
647
+ * @param {Project} project
648
+ * @param {string} file the old index.html
649
+ * @param {string[]} names the module prefix and the package name, which name the old app bundle
650
+ * @param {MigrationReport} report
651
+ * @returns {string}
652
+ */
653
+ function rewriteIndexHtml(project, file, names, report) {
654
+ let base = readFileSync(join(BASE_FILES, "index.html"), "utf-8");
655
+
656
+ if (!project.hasFile(file)) return base;
657
+
658
+ let html = readFileSync(project.path(file), "utf-8");
659
+ let boot = /** @type {string} */ (
660
+ /[ \t]*<script type="module">[\s\S]*?<\/script>\n/.exec(base)?.[0]
661
+ );
662
+ let stylesheet = project.hasFile("app/styles/app.css")
663
+ ? `<link rel="stylesheet" href="/app/styles/app.css" />`
664
+ : "";
665
+ let placedStylesheet = false;
666
+ let bundles = names.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
667
+ let bundle = `(?:assets\\/(?:vendor|test-support|${bundles})\\.(?:css|js)|@embroider\\/virtual\\/)`;
668
+ let buildScript = new RegExp(
669
+ `^[ \\t]*<script\\b[^>]*\\bsrc="[^"]*${bundle}[^"]*"[^>]*><\\/script>[ \\t]*\\n`,
670
+ "gm",
671
+ );
672
+ let buildStylesheet = new RegExp(
673
+ `^([ \\t]*)<link\\b[^>]*\\bhref="[^"]*${bundle}[^"]*"[^>]*>[ \\t]*\\n`,
674
+ "gm",
675
+ );
676
+
677
+ let result = html
678
+ .replace(CONTENT_FOR, "")
679
+ .replace(MODULE_SCRIPT, (script) =>
680
+ /Application|config\/environment/.test(script) ? "" : script,
681
+ )
682
+ .replace(buildScript, "")
683
+ .replace(buildStylesheet, (_link, indent) => {
684
+ if (placedStylesheet || !stylesheet) return "";
685
+
686
+ placedStylesheet = true;
687
+
688
+ return `${indent}${stylesheet}\n`;
689
+ })
690
+ .replace(/\{\{rootURL\}\}/g, "/")
691
+ .replace(/[ \t]*<\/body>/, `${boot} </body>`)
692
+ // blank lines where the removed lines were
693
+ .replace(/(<(?:head|body)\b[^>]*>\n)(?:[ \t]*\n)+/g, "$1")
694
+ .replace(/\n(?:[ \t]*\n)+([ \t]*<\/(?:head|body)>)/g, "\n$1")
695
+ .replace(/\n{3,}/g, "\n\n");
696
+
697
+ let leftover = result.match(/\{\{[^}]+\}\}/g) ?? [];
698
+
699
+ if (leftover.length > 0) {
700
+ report.todo.push({
701
+ title: "ember-cli placeholders in index.html",
702
+ where: leftover,
703
+ action: "Replace each placeholder with its value. Vite does not fill them in.",
704
+ });
705
+ }
706
+
707
+ return result;
708
+ }
709
+
710
+ const CONTENT_FOR = /^[ \t]*\{\{content-for\s+["'][^"']+["']\s*\}\}[ \t]*\n/gm;
711
+ const MODULE_SCRIPT = /^[ \t]*<script\b[^>]*\btype="module"[^>]*>[\s\S]*?<\/script>[ \t]*\n/gm;
712
+
713
+ /**
714
+ * Imports and class members that the blueprints' app/app.ts has.
715
+ * The base's app module replaces all of them.
716
+ */
717
+ const BLUEPRINT_APP_IMPORTS =
718
+ /^(@ember\/application|@embroider\/virtual\/compat-modules|ember-resolver|ember-load-initializers|@embroider\/macros|@embroider\/legacy-inspector-support.*|@warp-drive\/ember\/install|.*\/config\/environment|\.\/config\/environment|\.\/deprecation-workflow)$/;
719
+ const BLUEPRINT_APP_MEMBERS = new Set(["modulePrefix", "podModulePrefix", "Resolver", "inspector"]);
720
+
721
+ /**
722
+ * @typedef {object} AppModuleOptions
723
+ * @property {boolean} typescript
724
+ * @property {import('./addons.js').AppModule[]} services addon services to register
725
+ * @property {boolean} initializers whether the app has initializers
726
+ */
727
+
728
+ /**
729
+ * Writes the base's app module, with what this app needs on top:
730
+ * - WarpDrive's reactivity, when the old app module installed it
731
+ * - the deprecation workflow in development, when the app has one
732
+ * - the services that addons provided through app-js
733
+ * - the app's folders that Ember looks up by name, beyond the base's
734
+ * - the app's initializers
735
+ *
736
+ * @param {Project} project
737
+ * @param {MigrationReport} report
738
+ * @param {AppModuleOptions} options
739
+ */
740
+ async function writeAppModule(project, report, { typescript, services, initializers }) {
741
+ let root = project.directory;
742
+ let oldFile = firstExisting(root, ["app/app.ts", "app/app.js"]);
743
+ let old = oldFile ? readFileSync(project.path(oldFile), "utf-8") : "";
744
+ let base = readFileSync(join(BASE_FILES, "app/app.ts"), "utf-8");
745
+ let comment = base.slice(0, base.indexOf("import Application"));
746
+ let baseEntries = /** @type {string} */ (/ modules = \{\n([\s\S]*?)\n \};/.exec(base)?.[1]);
747
+
748
+ /** @type {string[]} */
749
+ let lines = [comment.trimEnd()];
750
+
751
+ if (/["']@warp-drive\/ember\/install["']/.test(old)) {
752
+ lines.push(`import "@warp-drive/ember/install";`);
753
+ }
754
+
755
+ lines.push(`import Application from "@ember/application";`);
756
+
757
+ let deprecationWorkflow = firstExisting(root, [
758
+ "app/deprecation-workflow.ts",
759
+ "app/deprecation-workflow.js",
760
+ ]);
761
+
762
+ if (deprecationWorkflow) {
763
+ lines.push(`import { importSync, isDevelopingApp, macroCondition } from "@embroider/macros";`);
764
+ }
765
+
766
+ if (initializers) {
767
+ lines.push(`import loadInitializers from "ember-load-initializers";`);
768
+ }
769
+
770
+ for (let service of services) {
771
+ lines.push(`import ${toIdentifier(service.path)} from "${service.specifier}";`);
772
+ }
773
+
774
+ if (deprecationWorkflow) {
775
+ lines.push(
776
+ "",
777
+ "if (macroCondition(isDevelopingApp())) {",
778
+ ` importSync("./deprecation-workflow");`,
779
+ "}",
780
+ );
781
+ }
782
+
783
+ lines.push("", "export default class App extends Application {", " modules = {");
784
+
785
+ // first, so that the app's own services of the same name win
786
+ for (let service of services) {
787
+ lines.push(` "./${service.path}": ${toIdentifier(service.path)},`);
788
+ }
789
+
790
+ lines.push(baseEntries);
791
+
792
+ for (let folder of REGISTERED_FOLDERS) {
793
+ if (listFiles(root, `app/${folder}`).some((file) => !file.endsWith(".gitkeep"))) {
794
+ lines.push(` ...import.meta.glob("./${folder}/**/*", { eager: true }),`);
795
+ }
796
+ }
797
+
798
+ lines.push(" };", "}");
799
+
800
+ if (initializers) {
801
+ lines.push(
802
+ "",
803
+ `const initializers = import.meta.glob("./{initializers,instance-initializers}/*", { eager: true });`,
804
+ "",
805
+ "// each initializer is named after its file, without the extension",
806
+ "loadInitializers(",
807
+ " App,",
808
+ ` ".",`,
809
+ " Object.fromEntries(",
810
+ ` Object.entries(initializers).map(([path, module]) => [path.replace(/\\.\\w+$/, ""), module]),`,
811
+ " ),",
812
+ ");",
813
+ );
814
+ }
815
+
816
+ let custom = oldFile ? customAppCode(old, oldFile) : [];
817
+
818
+ if (custom.length > 0) {
819
+ report.todo.push({
820
+ title: "Code in the old app module that the new one does not have",
821
+ where: custom,
822
+ action: `Add the code that you still need to ${typescript ? "app/app.ts" : "app/app.js"}.`,
823
+ });
824
+ }
825
+
826
+ await remove(project, ["app/app.ts", "app/app.js"]);
827
+ await writeFile(project.path(typescript ? "app/app.ts" : "app/app.js"), lines.join("\n") + "\n");
828
+ }
829
+
830
+ /**
831
+ * @param {string} source
832
+ * @param {string} file
833
+ * @returns {string[]} imports and class members beyond the blueprints'
834
+ */
835
+ function customAppCode(source, file) {
836
+ let program = parseProgram(source, file);
837
+
838
+ if (!program) return [`${file}: ember.nvp cannot read this file`];
839
+
840
+ /** @type {string[]} */
841
+ let custom = [];
842
+
843
+ for (let module of new Set(importedNames(program).values())) {
844
+ if (!BLUEPRINT_APP_IMPORTS.test(module)) custom.push(`import from ${module}`);
845
+ }
846
+
847
+ walk(program, (node) => {
848
+ if (node.type !== "ClassBody") return;
849
+
850
+ for (let member of node.body ?? []) {
851
+ let name = member.key?.name ?? "...";
852
+
853
+ if (!BLUEPRINT_APP_MEMBERS.has(name)) custom.push(`class member ${name}`);
854
+ }
855
+ });
856
+
857
+ for (let statement of program.body ?? []) {
858
+ if (statement.type !== "ExpressionStatement") continue;
859
+
860
+ let code = sourceOf(source, statement).replace(/\s+/g, " ");
861
+
862
+ if (!/^loadInitializers\(/.test(code)) custom.push(code);
863
+ }
864
+
865
+ for (let statement of program.body ?? []) {
866
+ if (
867
+ statement.type === "IfStatement" &&
868
+ !sourceOf(source, statement).includes("deprecation-workflow")
869
+ ) {
870
+ custom.push(sourceOf(source, statement).replace(/\s+/g, " "));
871
+ }
872
+ }
873
+
874
+ return custom;
875
+ }
876
+
877
+ /**
878
+ * @param {string} path such as `services/page-title`
879
+ * @returns {string} such as `PageTitleService`
880
+ */
881
+ function toIdentifier(path) {
882
+ let slash = path.indexOf("/");
883
+ let words = path.slice(slash + 1).split(/[^a-zA-Z0-9]+/);
884
+ let suffix = path.slice(0, slash).replace(/s$/, "");
885
+
886
+ return (
887
+ words.map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join("") +
888
+ suffix.charAt(0).toUpperCase() +
889
+ suffix.slice(1)
890
+ );
891
+ }
892
+
893
+ /**
894
+ * The base's babel.config.js, plus the WarpDrive config that ember-cli-build set.
895
+ *
896
+ * @param {Project} project
897
+ * @param {MigrationReport} report
898
+ * @param {{ from: string, options: string } | undefined} warpDrive
899
+ */
900
+ async function replaceBabelConfig(project, report, warpDrive) {
901
+ let root = project.directory;
902
+ let old = firstExisting(root, [
903
+ "babel.config.mjs",
904
+ "babel.config.cjs",
905
+ "babel.config.js",
906
+ ".babelrc",
907
+ ".babelrc.json",
908
+ ]);
909
+ let config = old ? readBabelConfig(root, old) : undefined;
910
+ let extra = (config?.plugins ?? []).filter((plugin) => !BASE_BABEL_PLUGINS.has(plugin));
911
+ let transforms = (config?.templateTransforms ?? []).filter(
912
+ (transform) => !/templateCompatSupport|templateMacros/.test(transform),
913
+ );
914
+
915
+ if (extra.length > 0 || transforms.length > 0) {
916
+ report.todo.push({
917
+ title: "Babel plugins that the new babel.config.js does not have",
918
+ where: extra.concat(transforms.map((transform) => `template transform: ${transform}`)),
919
+ action: "Add the plugins that you still need to babel.config.js.",
920
+ });
921
+ }
922
+
923
+ if (old) await remove(project, [old]);
924
+
925
+ let base = readFileSync(join(BASE_FILES, "babel.config.js"), "utf-8");
926
+
927
+ if (warpDrive) {
928
+ base = base
929
+ .replace(
930
+ `import { buildMacros } from "@embroider/macros/babel";\n`,
931
+ (line) => `${line}import { setConfig } from "${warpDrive.from}";\n`,
932
+ )
933
+ .replace(
934
+ / configure\(config\) \{\n/,
935
+ (line) => `${line} setConfig(config, ${indent(warpDrive.options, " ")});\n\n`,
936
+ );
937
+ }
938
+
939
+ await writeFile(project.path("babel.config.js"), base);
940
+ }
941
+
942
+ /**
943
+ * Moves the lines after the first one, so that the least indented line starts at `prefix`.
944
+ *
945
+ * @param {string} source
946
+ * @param {string} prefix
947
+ */
948
+ function indent(source, prefix) {
949
+ let lines = source.split("\n");
950
+ let margin = Infinity;
951
+
952
+ for (let i = 1; i < lines.length; i++) {
953
+ let line = /** @type {string} */ (lines[i]);
954
+
955
+ if (line.trim()) margin = Math.min(margin, /^[ \t]*/.exec(line)?.[0].length ?? 0);
956
+ }
957
+
958
+ for (let i = 1; i < lines.length; i++) {
959
+ let line = /** @type {string} */ (lines[i]);
960
+
961
+ lines[i] = line.trim() ? prefix + line.slice(margin) : "";
962
+ }
963
+
964
+ return lines.join("\n");
965
+ }
966
+
967
+ /**
968
+ * The blueprint's vite.config builds with @embroider/vite and compat.
969
+ * The base writes its own, so this lists what the old one had beyond the blueprint's.
970
+ *
971
+ * @param {Project} project
972
+ * @param {MigrationReport} report
973
+ */
974
+ async function replaceViteConfig(project, report) {
975
+ let file = firstExisting(project.directory, [
976
+ "vite.config.mjs",
977
+ "vite.config.js",
978
+ "vite.config.ts",
979
+ "vite.config.mts",
980
+ ]);
981
+
982
+ if (!file) return;
983
+
984
+ let source = readFileSync(project.path(file), "utf-8");
985
+ let program = parseProgram(source, file);
986
+ /** @type {string[]} */
987
+ let extra = [];
988
+
989
+ if (program) {
990
+ let imports = importedNames(program);
991
+
992
+ walk(program, (node) => {
993
+ if (node.type !== "CallExpression" || node.callee?.name !== "defineConfig") return;
994
+
995
+ let options = node.arguments?.[0];
996
+
997
+ for (let name of propertyNames(options)) {
998
+ if (name !== "plugins") extra.push(`${name}: ...`);
999
+ }
1000
+
1001
+ let plugins = propertyValue(options, "plugins");
1002
+
1003
+ for (let plugin of plugins?.elements ?? []) {
1004
+ let callee = plugin?.type === "CallExpression" ? plugin.callee : undefined;
1005
+ let from = callee?.type === "Identifier" ? imports.get(callee.name) : undefined;
1006
+
1007
+ if (from === "@embroider/vite" || from === "@rollup/plugin-babel") continue;
1008
+ if (
1009
+ plugin?.type === "SpreadElement" &&
1010
+ /classicEmberSupport/.test(sourceOf(source, plugin))
1011
+ )
1012
+ continue;
1013
+
1014
+ extra.push(sourceOf(source, plugin).replace(/\s+/g, " "));
1015
+ }
1016
+ });
1017
+ } else {
1018
+ extra.push(`${file}: ember.nvp cannot read this file`);
1019
+ }
1020
+
1021
+ if (extra.length > 0) {
1022
+ report.todo.push({
1023
+ title: "Vite settings that the new vite.config.mjs does not have",
1024
+ where: extra,
1025
+ action: "Add the settings that you still need to vite.config.mjs.",
1026
+ });
1027
+ }
1028
+
1029
+ await remove(project, [file]);
1030
+ }
1031
+
1032
+ /**
1033
+ * `<modulePrefix>/config/environment` is `#config` now.
1034
+ *
1035
+ * When the module prefix is not the package name,
1036
+ * imports from the module prefix move to the package name,
1037
+ * which `exports` in package.json maps to app/.
1038
+ *
1039
+ * @param {Project} project
1040
+ * @param {string} modulePrefix
1041
+ * @param {string} name
1042
+ */
1043
+ async function rewriteImports(project, modulePrefix, name) {
1044
+ let prefix = modulePrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1045
+ let config = new RegExp(`(["'])${prefix}/config/environment(\\.\\w+)?\\1`, "g");
1046
+ let other = new RegExp(`(["'])${prefix}/`, "g");
1047
+
1048
+ for (let file of filesWithExtension(
1049
+ project.directory,
1050
+ ["app", "tests", "types"],
1051
+ [".js", ".ts", ".gjs", ".gts"],
1052
+ )) {
1053
+ let path = project.path(file);
1054
+ let source = readFileSync(path, "utf-8");
1055
+ let result = source.replace(config, "$1#config$1");
1056
+
1057
+ if (modulePrefix !== name) {
1058
+ result = result.replace(other, `$1${name}/`);
1059
+ }
1060
+
1061
+ if (result !== source) await writeFile(path, result);
1062
+ }
1063
+ }
1064
+
1065
+ /**
1066
+ * Brings the base's scripts, subpath imports, exports, and dependencies,
1067
+ * and removes the ember-cli toolchain.
1068
+ *
1069
+ * @param {Project} project
1070
+ * @param {{ initializers: boolean }} options
1071
+ */
1072
+ async function usePackageJsonOfBase(project, { initializers }) {
1073
+ let root = project.directory;
1074
+ let manifest = await packageJson.read(root);
1075
+ let removed = Array.from(removedDependencies(root));
1076
+
1077
+ if (!initializers) removed.push("ember-load-initializers");
1078
+
1079
+ await packageJson.removeDependencies(removed, root);
1080
+ await packageJson.removeDevDependencies(removed, root);
1081
+
1082
+ /** @type {Record<string, string>} */
1083
+ let dependencies = {};
1084
+ /** @type {Record<string, string>} */
1085
+ let devDependencies = {};
1086
+
1087
+ for (let [name, range] of Object.entries(BASE_MANIFEST.dependencies)) {
1088
+ if (!manifest.dependencies?.[name] && !manifest.devDependencies?.[name])
1089
+ dependencies[name] = String(range);
1090
+ }
1091
+
1092
+ for (let [name, range] of Object.entries(BASE_MANIFEST.devDependencies)) {
1093
+ if (!manifest.dependencies?.[name] && !manifest.devDependencies?.[name])
1094
+ devDependencies[name] = String(range);
1095
+ }
1096
+
1097
+ await packageJson.addDependencies(await getLatest(dependencies), root);
1098
+ await packageJson.addDevDependencies(await getLatest(devDependencies), root);
1099
+
1100
+ await packageJson.modify((json) => {
1101
+ json.type = "module";
1102
+ json.scripts ||= {};
1103
+
1104
+ for (let [script, command] of Object.entries(BASE_MANIFEST.scripts)) {
1105
+ json.scripts[script] = command;
1106
+ }
1107
+
1108
+ for (let [script, command] of Object.entries(json.scripts)) {
1109
+ if (/\bember (test|serve|build|s|t|b)\b|\btestem\b/.test(String(command))) {
1110
+ delete json.scripts[script];
1111
+ }
1112
+ }
1113
+
1114
+ json.imports = { ...BASE_MANIFEST.imports, ...json.imports };
1115
+ json.exports ??= BASE_MANIFEST.exports;
1116
+ }, root);
1117
+ }