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,60 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { readJSON } from "#utils/fs.js";
4
+ import { checkApp, migrateApp } from "../../app.js";
5
+ import { EMBER_CLI_BUILD } from "../../ember-cli-build.js";
6
+ import { firstExisting } from "../../files.js";
7
+
8
+ const VITE_CONFIGS = ["vite.config.mjs", "vite.config.js", "vite.config.ts", "vite.config.mts"];
9
+
10
+ /**
11
+ * An app that builds with Vite and `@embroider/vite`.
12
+ *
13
+ * This covers `@ember/app-blueprint`, and its predecessor `@embroider/app-blueprint`.
14
+ * Both keep an ember-cli-build for `@embroider/compat`,
15
+ * which lets v1 addons, `.hbs` templates, and the classic resolver keep working.
16
+ *
17
+ * ember.nvp apps build without compat.
18
+ * So the migration stops at each compat feature that the app still uses,
19
+ * and lists them for the user.
20
+ *
21
+ * @type {import('#types').Migration}
22
+ */
23
+ export default {
24
+ label: "@ember/app-blueprint",
25
+ type: "app",
26
+
27
+ detect(directory) {
28
+ let manifest = readJSON(join(directory, "package.json"));
29
+
30
+ // v2 addons build their tests and demo apps with @embroider/vite too
31
+ if (
32
+ manifest?.keywords?.includes("ember-addon") ||
33
+ manifest?.["ember-addon"]?.type === "addon"
34
+ ) {
35
+ return false;
36
+ }
37
+
38
+ if (!existsSync(join(directory, "app"))) return false;
39
+
40
+ let build = firstExisting(directory, EMBER_CLI_BUILD);
41
+
42
+ if (build && readFileSync(join(directory, build), "utf-8").includes("@embroider/vite")) {
43
+ return true;
44
+ }
45
+
46
+ let vite = firstExisting(directory, VITE_CONFIGS);
47
+
48
+ return Boolean(
49
+ vite && readFileSync(join(directory, vite), "utf-8").includes("@embroider/vite"),
50
+ );
51
+ },
52
+
53
+ async check(project) {
54
+ return checkApp(project);
55
+ },
56
+
57
+ async run(project, report) {
58
+ await migrateApp(project, report, { indexHtml: "index.html" });
59
+ },
60
+ };
@@ -0,0 +1,601 @@
1
+ import { packageJson } from "ember-apply";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { mkdir, writeFile } from "node:fs/promises";
4
+ import { dirname, join, relative } from "node:path";
5
+ import { readJSON } from "#utils/fs.js";
6
+ import { getLatest } from "#utils/npm.js";
7
+ import { rewriteImportsToMatchFiles } from "#utils/rewrite-imports.js";
8
+ import { installedDependencies, v1AddonsIn } from "../../addons.js";
9
+ import { TOOLCHAIN } from "../../app.js";
10
+ import {
11
+ filesWithExtension,
12
+ firstExisting,
13
+ listFiles,
14
+ move,
15
+ remove,
16
+ testsWithHbs,
17
+ } from "../../files.js";
18
+ import { parseProgram, propertyNames, propertyValue, stringValue, walk } from "../../javascript.js";
19
+ import { changedTestemFlags, removeToolingOfWantedLayers } from "../../layers.js";
20
+ import {
21
+ buildWithTsdown,
22
+ entriesFor,
23
+ renameCommonJSFiles,
24
+ useEmberSourceForTests,
25
+ usesCss,
26
+ } from "../../library.js";
27
+
28
+ /**
29
+ * @typedef {import('#types').MigrationReport} MigrationReport
30
+ * @typedef {import('#utils/project.js').Project} Project
31
+ */
32
+
33
+ /**
34
+ * Keys of the addon object in index.js that a v2 addon can do without
35
+ */
36
+ const HARMLESS_ADDON_KEYS = new Set(["name", "moduleName", "isDevelopingAddon", "options"]);
37
+
38
+ /**
39
+ * `options` in index.js that only configure ember-cli-babel
40
+ */
41
+ const HARMLESS_ADDON_OPTIONS = new Set(["ember-cli-babel"]);
42
+
43
+ /**
44
+ * A v1 addon: ember-cli loads its index.js, and merges addon/ and app/ into the app.
45
+ *
46
+ * This covers `@ember-tooling/classic-build-addon-blueprint`,
47
+ * and the `addon` blueprint that ember-cli had before it.
48
+ *
49
+ * The migration makes a v2 addon that builds with tsdown:
50
+ * - addon/ moves to src/, and addon-test-support/ to src/test-support/
51
+ * - app/ re-exports come back from `appReexports(...)` in tsdown.config.js
52
+ * - addon-main.cjs lets apps with ember-cli keep using the addon
53
+ *
54
+ * @type {import('#types').Migration}
55
+ */
56
+ export default {
57
+ label: "@ember-tooling/classic-build-addon-blueprint",
58
+ type: "library",
59
+
60
+ detect(directory) {
61
+ let manifest = readJSON(join(directory, "package.json"));
62
+
63
+ if (!manifest?.keywords?.includes("ember-addon")) return false;
64
+ if (manifest["ember-addon"]?.version === 2) return false;
65
+
66
+ return existsSync(join(directory, manifest["ember-addon"]?.main ?? "index.js"));
67
+ },
68
+
69
+ async check(project) {
70
+ /** @type {MigrationReport} */
71
+ let report = { unsupported: [], todo: [], layers: [] };
72
+ let root = project.directory;
73
+ let manifest = await packageJson.read(root);
74
+ let main = manifest["ember-addon"]?.main ?? "index.js";
75
+
76
+ let hooks = addonHooks(root, main);
77
+
78
+ if (hooks.length > 0) {
79
+ report.unsupported.push({
80
+ title: "ember-cli hooks in the addon's main file",
81
+ where: hooks,
82
+ action:
83
+ "Move what each hook does into the addon's modules, or into setup steps for apps.\n" +
84
+ "A v2 addon has no build hooks.",
85
+ });
86
+ }
87
+
88
+ let templates = filesWithExtension(root, ["addon", "app"], [".hbs"]);
89
+
90
+ if (templates.length > 0) {
91
+ report.unsupported.push({
92
+ title: "Templates in .hbs files",
93
+ where: templates,
94
+ action:
95
+ "Move each template into its component as <template>, in a .gjs or .gts file.\n" +
96
+ "The build compiles <template> only.",
97
+ });
98
+ }
99
+
100
+ let styles = listFiles(root, "addon/styles").concat(listFiles(root, "app/styles"));
101
+
102
+ if (styles.length > 0) {
103
+ report.unsupported.push({
104
+ title: "Styles that ember-cli merges into the app",
105
+ where: styles,
106
+ action:
107
+ "Import each stylesheet from the modules that need it, such as `import './button.css';`.",
108
+ });
109
+ }
110
+
111
+ let assets = listFiles(root, "public")
112
+ .concat(listFiles(root, "vendor"))
113
+ .filter((file) => !file.endsWith(".gitkeep"));
114
+
115
+ if (assets.length > 0) {
116
+ report.unsupported.push({
117
+ title: "Files for the app's public or vendor tree",
118
+ where: assets,
119
+ action:
120
+ "Import each asset from the module that uses it, or publish the assets in another package.\n" +
121
+ "The build does not copy files into apps.",
122
+ });
123
+ }
124
+
125
+ if (existsSync(join(root, "config/environment.js")) && !isEmptyAddonConfig(root)) {
126
+ report.unsupported.push({
127
+ title: "Addon config from config/environment.js",
128
+ where: ["config/environment.js"],
129
+ action:
130
+ "Let apps pass the config to the addon's modules, or use @embroider/macros getOwnConfig.",
131
+ });
132
+ }
133
+
134
+ let reexports = readAppFolder(root, manifest.name);
135
+
136
+ if (reexports.custom.length > 0) {
137
+ report.unsupported.push({
138
+ title: "Modules in app/ that are not re-exports",
139
+ where: reexports.custom,
140
+ action:
141
+ "Move the code into addon/, and re-export it from app/ with the same path:\n" +
142
+ ` export { default } from '${manifest.name}/components/example';`,
143
+ });
144
+ }
145
+
146
+ if (reexports.renamed.length > 0) {
147
+ report.todo.push({
148
+ title: "app/ re-exports under another name",
149
+ where: reexports.renamed,
150
+ action:
151
+ "Add a `mapFilename` option to `appReexports(...)` in tsdown.config.js for each one.",
152
+ });
153
+ }
154
+
155
+ /** @type {Record<string, string>} */
156
+ let dependencies = {};
157
+
158
+ for (let [name, range] of Object.entries(manifest.dependencies ?? {})) {
159
+ if (!TOOLCHAIN.has(name)) dependencies[name] = String(range);
160
+ }
161
+
162
+ let v1 = v1AddonsIn(installedDependencies(project.desires.path, dependencies));
163
+
164
+ if (v1.length > 0) {
165
+ report.unsupported.push({
166
+ title: "v1 addons in dependencies",
167
+ where: v1,
168
+ action:
169
+ "Upgrade each addon to a v2 version, or remove it.\nApps without ember-cli cannot load v1 addons.",
170
+ });
171
+ }
172
+
173
+ if (listFiles(root, "tests/dummy").length > 0 && !isDefaultDummyApp(root)) {
174
+ report.todo.push({
175
+ title: "A dummy app",
176
+ where: ["tests/dummy/"],
177
+ action:
178
+ "Move the demo into its own app, and move acceptance tests with it. Then delete tests/dummy/.\n" +
179
+ "The test build loads every module in tests/, so it fails until tests/dummy/ is gone.",
180
+ });
181
+ }
182
+
183
+ let hbsTests = testsWithHbs(root, ["tests"]);
184
+
185
+ if (hbsTests.length > 0) {
186
+ report.todo.push({
187
+ title: "Tests that render hbs templates",
188
+ where: hbsTests,
189
+ action:
190
+ "Convert each test to .gjs or .gts, and render with <template>.\n" +
191
+ "The hbs template literal needs ember-cli.",
192
+ });
193
+ }
194
+
195
+ let testem = changedTestemFlags(root, "testem.js", "library");
196
+
197
+ if (testem) report.todo.push(testem);
198
+
199
+ if (listFiles(root, "blueprints").length > 0) {
200
+ report.todo.push({
201
+ title: "Blueprints for ember install",
202
+ where: ["blueprints/"],
203
+ action: "Check that `ember install` still runs them, or document the setup steps for apps.",
204
+ });
205
+ }
206
+
207
+ let emberTry = firstExisting(root, ["config/ember-try.js", "tests/dummy/config/ember-try.js"]);
208
+
209
+ if (emberTry) {
210
+ report.todo.push({
211
+ title: "ember-try scenarios",
212
+ where: [emberTry],
213
+ action:
214
+ "Move the scenarios that you still need to .try.mjs for @embroider/try.\n" +
215
+ "The new test setup needs ember-source 7.2 or later.",
216
+ });
217
+ }
218
+
219
+ if (
220
+ existsSync(join(root, "tsconfig.json")) ||
221
+ filesWithExtension(root, ["addon"], [".ts", ".gts"]).length > 0
222
+ ) {
223
+ report.layers.push("typescript");
224
+ report.todo.push({
225
+ title: "Declarations from isolatedDeclarations",
226
+ where: ["tsconfig.json"],
227
+ action:
228
+ "Run the build, and add the explicit types that TypeScript asks for.\n" +
229
+ "tsdown makes .d.ts files with isolatedDeclarations, which needs a type on each export.",
230
+ });
231
+ }
232
+
233
+ if (listFiles(root, "tests").some((file) => /-test\.(js|ts|gjs|gts)$/.test(file))) {
234
+ report.layers.push("qunit");
235
+ }
236
+
237
+ return report;
238
+ },
239
+
240
+ async run(project, report) {
241
+ let root = project.directory;
242
+ let manifest = await packageJson.read(root);
243
+ let name = manifest.name;
244
+ let main = manifest["ember-addon"]?.main ?? "index.js";
245
+ let typescript = await project.hasOrWantsLayer("typescript");
246
+ let reexports = readAppFolder(root, name);
247
+ let testSupport = listFiles(root, "addon-test-support").length > 0;
248
+
249
+ await removeToolingOfWantedLayers(project, report);
250
+
251
+ for (let file of listFiles(root, "addon")) {
252
+ if (file.endsWith(".gitkeep")) continue;
253
+
254
+ await move(project, file, file.replace(/^addon\//, "src/"));
255
+ }
256
+
257
+ for (let file of listFiles(root, "addon-test-support")) {
258
+ await move(project, file, file.replace(/^addon-test-support\//, "src/test-support/"));
259
+ }
260
+
261
+ if (!project.hasFile("src/index.js") && !project.hasFile("src/index.ts")) {
262
+ await mkdir(project.path("src"), { recursive: true });
263
+ await writeFile(project.path(typescript ? "src/index.ts" : "src/index.js"), "export {};\n");
264
+ }
265
+
266
+ if (isDefaultDummyApp(root)) {
267
+ await remove(project, ["tests/dummy"]);
268
+ }
269
+
270
+ if (isEmptyAddonConfig(root)) {
271
+ await remove(project, ["config/environment.js"]);
272
+ }
273
+
274
+ await remove(project, [
275
+ "addon",
276
+ "addon-test-support",
277
+ "app",
278
+ "public/.gitkeep",
279
+ "vendor/.gitkeep",
280
+ main,
281
+ "ember-cli-build.js",
282
+ "testem.js",
283
+ "tests/index.html",
284
+ "tests/test-helper.js",
285
+ "tests/test-helper.ts",
286
+ ".npmignore",
287
+ ".ember-cli",
288
+ ".watchmanconfig",
289
+ "tsconfig.declarations.json",
290
+ // the library base writes its own
291
+ "tsconfig.json",
292
+ ]);
293
+
294
+ await writeFile(
295
+ project.path("addon-main.cjs"),
296
+ `"use strict";\n\nconst { addonV1Shim } = require("@embroider/addon-shim");\n\nmodule.exports = addonV1Shim(__dirname);\n`,
297
+ );
298
+
299
+ await packageJson.removeDependencies(Array.from(TOOLCHAIN), root);
300
+ await packageJson.removeDevDependencies(Array.from(TOOLCHAIN), root);
301
+ await packageJson.addDependencies(
302
+ await getLatest({ "@embroider/addon-shim": "^1.10.3", "decorator-transforms": "^2.4.0" }),
303
+ root,
304
+ );
305
+
306
+ await packageJson.modify((json) => {
307
+ json["ember-addon"] = { version: 2, type: "addon", main: "addon-main.cjs" };
308
+ json.files ??= ["addon-main.cjs", "dist", "src"];
309
+ json.exports ??= exportsFor(typescript, testSupport);
310
+ json.imports = { ...json.imports, "#src/*": "./src/*" };
311
+
312
+ for (let [script, command] of Object.entries(json.scripts ?? {})) {
313
+ if (/\bember (test|serve|build|try:\S+|s|t|b)\b/.test(String(command))) {
314
+ delete json.scripts[script];
315
+ }
316
+ }
317
+ }, root);
318
+
319
+ await rewriteSelfImports(project, name);
320
+ await renameCommonJSFiles(project);
321
+
322
+ await buildWithTsdown(project, {
323
+ entry: entriesFor("src", ["**/*.js"]),
324
+ appReexports: reexports.globs.length > 0 ? reexports.globs : undefined,
325
+ css: usesCss(root, "src"),
326
+ typescript,
327
+ outDir: undefined,
328
+ });
329
+
330
+ await useEmberSourceForTests(project);
331
+
332
+ if (
333
+ listFiles(root, "tests").some((file) => /-test\.(js|ts|gjs|gts)$/.test(file)) &&
334
+ !project.wantsLayer("qunit")
335
+ ) {
336
+ report.todo.push({
337
+ title: "Tests without a test setup",
338
+ where: ["tests/"],
339
+ action: "Run ember.nvp again with the qunit layer, or delete the tests.",
340
+ });
341
+ }
342
+ },
343
+ };
344
+
345
+ /**
346
+ * @param {string} root
347
+ * @param {string} main
348
+ * @returns {string[]} hooks and options of the addon object that a v2 addon cannot have
349
+ */
350
+ function addonHooks(root, main) {
351
+ let path = join(root, main);
352
+
353
+ if (!existsSync(path)) return [];
354
+
355
+ let source = readFileSync(path, "utf-8");
356
+ let program = parseProgram(source, main);
357
+
358
+ if (!program) return [`${main}: ember.nvp cannot read this file`];
359
+
360
+ /** @type {import('../../javascript.js').Node | undefined} */
361
+ let addon;
362
+
363
+ walk(program, (node) => {
364
+ if (node.type !== "AssignmentExpression") return;
365
+
366
+ let left = node.left;
367
+
368
+ if (
369
+ left?.type === "MemberExpression" &&
370
+ left.object?.name === "module" &&
371
+ left.property?.name === "exports"
372
+ ) {
373
+ addon = node.right;
374
+ }
375
+ });
376
+
377
+ if (addon?.type !== "ObjectExpression") {
378
+ return [`${main}: module.exports is not an object`];
379
+ }
380
+
381
+ /** @type {string[]} */
382
+ let found = [];
383
+
384
+ for (let key of propertyNames(addon)) {
385
+ if (!HARMLESS_ADDON_KEYS.has(key)) found.push(`${main}: ${key}`);
386
+ }
387
+
388
+ for (let option of propertyNames(propertyValue(addon, "options"))) {
389
+ if (!HARMLESS_ADDON_OPTIONS.has(option)) found.push(`${main}: options.${option}`);
390
+ }
391
+
392
+ return found;
393
+ }
394
+
395
+ /**
396
+ * The blueprint's config/environment.js gives the addon no config:
397
+ *
398
+ * module.exports = function () { return {}; };
399
+ *
400
+ * @param {string} root
401
+ */
402
+ function isEmptyAddonConfig(root) {
403
+ let path = join(root, "config/environment.js");
404
+
405
+ if (!existsSync(path)) return false;
406
+
407
+ let source = readFileSync(path, "utf-8")
408
+ .replace(/\/\*[\s\S]*?\*\//g, "")
409
+ .replace(/\/\/.*$/gm, "");
410
+
411
+ return /^\s*(["']use strict["'];?)?\s*module\.exports\s*=\s*function\s*\([^)]*\)\s*\{\s*return\s*\{\s*\};?\s*\};?\s*$/.test(
412
+ source,
413
+ );
414
+ }
415
+
416
+ /**
417
+ * Files of the blueprint's dummy app, relative to tests/dummy/app/
418
+ */
419
+ const DUMMY_APP_FILES = new Set([
420
+ "app.js",
421
+ "app.ts",
422
+ "config/environment.d.ts",
423
+ "deprecation-workflow.js",
424
+ "deprecation-workflow.ts",
425
+ "index.html",
426
+ "router.js",
427
+ "router.ts",
428
+ "styles/app.css",
429
+ "templates/application.gjs",
430
+ "templates/application.gts",
431
+ "templates/application.hbs",
432
+ ]);
433
+
434
+ /**
435
+ * What the blueprint's application template has
436
+ */
437
+ const DUMMY_TEMPLATE =
438
+ /import\s+\w+\s+from\s+["']ember-page-title\/helpers\/page-title["'];?|<\/?template>|\{\{\s*page-?[tT]itle\s+["'][^"']*["']\s*\}\}|<h2 id="title">Welcome to Ember<\/h2>|\{\{outlet\}\}/g;
439
+
440
+ /**
441
+ * Whether tests/dummy/ is still the blueprint's: no routes, no components, no demo of its own.
442
+ *
443
+ * @param {string} root
444
+ */
445
+ function isDefaultDummyApp(root) {
446
+ let files = listFiles(root, "tests/dummy/app").filter((file) => !file.endsWith(".gitkeep"));
447
+
448
+ if (files.length === 0) return false;
449
+
450
+ for (let file of files) {
451
+ if (!DUMMY_APP_FILES.has(file.replace(/^tests\/dummy\/app\//, ""))) return false;
452
+ }
453
+
454
+ let router = firstExisting(root, ["tests/dummy/app/router.js", "tests/dummy/app/router.ts"]);
455
+
456
+ if (router && /this\.route\(/.test(readFileSync(join(root, router), "utf-8"))) return false;
457
+
458
+ let template = firstExisting(root, [
459
+ "tests/dummy/app/templates/application.gjs",
460
+ "tests/dummy/app/templates/application.gts",
461
+ "tests/dummy/app/templates/application.hbs",
462
+ ]);
463
+
464
+ if (!template) return true;
465
+
466
+ return readFileSync(join(root, template), "utf-8").replace(DUMMY_TEMPLATE, "").trim() === "";
467
+ }
468
+
469
+ /**
470
+ * @typedef {object} AppFolder
471
+ * @property {string[]} globs `appReexports` globs for the built files that app/ re-exports
472
+ * @property {string[]} renamed re-exports whose path differs from the module they re-export
473
+ * @property {string[]} custom modules in app/ with code of their own
474
+ */
475
+
476
+ /**
477
+ * @param {string} root
478
+ * @param {string} name the addon's package name
479
+ * @returns {AppFolder}
480
+ */
481
+ function readAppFolder(root, name) {
482
+ /** @type {AppFolder} */
483
+ let result = { globs: [], renamed: [], custom: [] };
484
+
485
+ for (let file of listFiles(root, "app")) {
486
+ if (file.endsWith(".gitkeep") || file.endsWith(".hbs")) continue;
487
+
488
+ let path = file.replace(/^app\//, "").replace(/\.\w+$/, "");
489
+ let target = reexportedFrom(readFileSync(join(root, file), "utf-8"), file);
490
+
491
+ if (!target) {
492
+ result.custom.push(file);
493
+ } else if (target !== `${name}/${path}` && `${target}/index` !== `${name}/${path}`) {
494
+ result.renamed.push(`${file}: ${target}`);
495
+ } else {
496
+ result.globs.push(`${path}.js`);
497
+ }
498
+ }
499
+
500
+ return result;
501
+ }
502
+
503
+ /**
504
+ * The module that an app/ file re-exports, in either form:
505
+ *
506
+ * export { default } from 'my-addon/components/foo';
507
+ *
508
+ * import Foo from 'my-addon/components/foo';
509
+ * export default Foo;
510
+ *
511
+ * @param {string} source
512
+ * @param {string} file
513
+ * @returns {string | undefined} undefined when the file has code of its own
514
+ */
515
+ function reexportedFrom(source, file) {
516
+ let program = parseProgram(source, file);
517
+ let body = program?.body ?? [];
518
+
519
+ if (body.length === 1 && body[0].type === "ExportNamedDeclaration" && body[0].source) {
520
+ return stringValue(body[0].source);
521
+ }
522
+
523
+ let [first, second] = body;
524
+
525
+ if (
526
+ body.length === 2 &&
527
+ first?.type === "ImportDeclaration" &&
528
+ first.specifiers?.length === 1 &&
529
+ first.specifiers[0].type === "ImportDefaultSpecifier" &&
530
+ second?.type === "ExportDefaultDeclaration" &&
531
+ second.declaration?.name === first.specifiers[0].local.name
532
+ ) {
533
+ return stringValue(first.source);
534
+ }
535
+ }
536
+
537
+ /**
538
+ * @param {boolean} typescript
539
+ * @param {boolean} testSupport
540
+ * @returns {Record<string, unknown>}
541
+ */
542
+ function exportsFor(typescript, testSupport) {
543
+ /**
544
+ * @param {string} path
545
+ */
546
+ let entry = (path) =>
547
+ typescript
548
+ ? { types: `./dist/${path}.d.ts`, default: `./dist/${path}.js` }
549
+ : `./dist/${path}.js`;
550
+
551
+ /** @type {Record<string, unknown>} */
552
+ let exports = { ".": entry("index") };
553
+
554
+ if (testSupport) {
555
+ exports["./test-support"] = entry("test-support/index");
556
+ }
557
+
558
+ exports["./addon-main.js"] = "./addon-main.cjs";
559
+ exports["./*"] = entry("*");
560
+
561
+ return exports;
562
+ }
563
+
564
+ /**
565
+ * A v1 addon imports its own modules by the addon's name.
566
+ * A v2 addon imports them by path:
567
+ * - relative paths in src/
568
+ * - `#src/*` in tests, so that tests run against the source
569
+ *
570
+ * @param {Project} project
571
+ * @param {string} name
572
+ */
573
+ async function rewriteSelfImports(project, name) {
574
+ let escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
575
+ let selfImport = new RegExp(`((?:from|import)\\s*\\(?\\s*)(["'])${escaped}(/[^"']*)?\\2`, "g");
576
+
577
+ for (let file of filesWithExtension(
578
+ project.directory,
579
+ ["src", "tests"],
580
+ [".js", ".ts", ".gjs", ".gts"],
581
+ )) {
582
+ if (file.startsWith("tests/dummy/")) continue;
583
+
584
+ let path = project.path(file);
585
+ let source = readFileSync(path, "utf-8");
586
+
587
+ let result = source.replace(selfImport, (_match, keyword, quote, subpath = "") => {
588
+ let target = subpath === "" ? "index" : subpath.slice(1);
589
+
590
+ if (file.startsWith("tests/")) return `${keyword}${quote}#src/${target}${quote}`;
591
+
592
+ let specifier = relative(dirname(file), `src/${target}`).split("\\").join("/");
593
+
594
+ return `${keyword}${quote}${specifier.startsWith(".") ? specifier : `./${specifier}`}${quote}`;
595
+ });
596
+
597
+ if (result === source) continue;
598
+
599
+ await writeFile(path, rewriteImportsToMatchFiles(result, path));
600
+ }
601
+ }