ember.nvp 0.5.0 → 0.7.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 (91) hide show
  1. package/.github/workflows/ci.yml +17 -2
  2. package/.release-plan.json +84 -4
  3. package/CHANGELOG.md +68 -0
  4. package/README.md +29 -0
  5. package/package.json +4 -2
  6. package/packages/build-tooling-utils/README.md +37 -0
  7. package/packages/build-tooling-utils/index.ts +2 -0
  8. package/packages/build-tooling-utils/package.json +42 -0
  9. package/packages/build-tooling-utils/rolldown.config.js +18 -0
  10. package/packages/build-tooling-utils/src/extensions.ts +18 -0
  11. package/packages/build-tooling-utils/src/maybe-babel.ts +146 -0
  12. package/packages/rolldown/README.md +115 -0
  13. package/packages/rolldown/index.ts +56 -0
  14. package/packages/rolldown/package.json +59 -0
  15. package/packages/rolldown/rolldown.config.js +21 -0
  16. package/packages/rolldown/src/babel.ts +136 -0
  17. package/packages/rolldown/src/define-config.ts +32 -0
  18. package/packages/rolldown/src/externals.ts +83 -0
  19. package/packages/rolldown/src/isolated-declarations.ts +65 -0
  20. package/packages/rolldown/src/transform.ts +162 -0
  21. package/packages/vite/README.md +7 -21
  22. package/packages/vite/index.ts +1 -1
  23. package/packages/vite/package.json +4 -6
  24. package/packages/vite/src/ember.ts +129 -15
  25. package/packages/vite/src/test-html.ts +86 -0
  26. package/src/bases/minimal-app/files/app/router.ts +1 -0
  27. package/src/bases/minimal-app/files/tsconfig.json +1 -0
  28. package/src/bases/minimal-app/index.js +2 -2
  29. package/src/bases/minimal-library/files/README.md +41 -0
  30. package/src/bases/minimal-library/files/package.json +49 -0
  31. package/src/bases/minimal-library/files/tsconfig.json +10 -0
  32. package/src/bases/minimal-library/files/tsdown.config.js +6 -0
  33. package/src/bases/minimal-library/index.js +158 -2
  34. package/src/cli/args.js +7 -1
  35. package/src/cli/index.js +90 -16
  36. package/src/cli/questions/layers.js +5 -2
  37. package/src/cli/questions/project-type.js +0 -5
  38. package/src/cli/questions/write.js +169 -0
  39. package/src/index.js +1 -0
  40. package/src/layers/are-the-types-wrong/index.js +74 -0
  41. package/src/layers/git/files/gitignore +14 -1
  42. package/src/layers/git/index.js +6 -5
  43. package/src/layers/github-actions/index.js +12 -8
  44. package/src/layers/index.js +1 -1
  45. package/src/layers/inspector-support/index.js +101 -0
  46. package/src/layers/prettier/files/.prettierignore +4 -0
  47. package/src/layers/publint/index.js +68 -0
  48. package/src/layers/qunit/files/tests/unit/macros-test.ts +2 -2
  49. package/src/layers/qunit/index.js +107 -7
  50. package/src/layers/qunit/library-files/config/test/babel.config.js +41 -0
  51. package/src/layers/qunit/library-files/config/test/testem.cjs +26 -0
  52. package/src/layers/qunit/library-files/tests/test-helper.ts +36 -0
  53. package/src/layers/qunit/library-files/tests/unit/.gitkeep +0 -0
  54. package/src/layers/qunit/library-files/vite.config.mjs +6 -0
  55. package/src/layers/typescript/index.js +43 -7
  56. package/src/layers/vitest/files/vitest.config.mjs +11 -11
  57. package/src/layers/vitest/index.js +70 -32
  58. package/src/utils/babel.js +9 -6
  59. package/src/utils/fs.js +44 -3
  60. package/src/utils/inspector-support.js +196 -0
  61. package/src/utils/npm.js +21 -7
  62. package/src/utils/remove-types.js +5 -0
  63. package/src/utils/rewrite-imports.js +157 -0
  64. package/src/utils/stage.js +317 -0
  65. package/src/utils/tsdown-config.js +85 -0
  66. package/src/utils/types.ts +5 -1
  67. package/test/ember.nvp/base-library.test.ts +232 -0
  68. package/test/ember.nvp/generator-replace-or-update.test.ts +2 -3
  69. package/test/ember.nvp/inspector-support.test.ts +179 -0
  70. package/test/ember.nvp/layer-inspector-support.test.ts +69 -0
  71. package/test/ember.nvp/layer-publish-checks.test.ts +103 -0
  72. package/test/ember.nvp/layer-qunit.test.ts +376 -15
  73. package/test/ember.nvp/layer-vitest.test.ts +250 -0
  74. package/test/ember.nvp/layers.test.ts +16 -0
  75. package/test/ember.nvp/library-src-fixtures.ts +81 -0
  76. package/test/ember.nvp/permutations/app-1.test.ts +3 -0
  77. package/test/ember.nvp/permutations/app-2.test.ts +3 -0
  78. package/test/ember.nvp/permutations/library-1.test.ts +3 -0
  79. package/test/ember.nvp/permutations/library-2.test.ts +3 -0
  80. package/test/ember.nvp/permutations/shared.ts +239 -0
  81. package/test/ember.nvp/rewrite-imports.test.ts +235 -0
  82. package/test/ember.nvp/stage.test.ts +188 -0
  83. package/test/test-helpers/permutate.test.ts +17 -0
  84. package/test/test-helpers.ts +20 -7
  85. package/tsconfig.json +1 -0
  86. package/packages/vite/src/maybe-babel.ts +0 -219
  87. package/src/layers/qunit/files/run-tests.mjs +0 -30
  88. package/src/layers/vitest/files/tests/test-helper.ts +0 -5
  89. package/test/ember.nvp/permutations.test.ts +0 -200
  90. /package/src/{layers/vitest/files/tests/integration/.gitkeep → bases/minimal-library/files/src/index.ts} +0 -0
  91. /package/src/layers/{vitest/files/tests/unit → qunit/library-files/tests/rendering}/.gitkeep +0 -0
@@ -1,7 +1,44 @@
1
+ import { applyFolderTo } from "#utils/fs.js";
1
2
  import { getLatest } from "#utils/npm.js";
2
- import { packageJson, files } from "ember-apply";
3
+ import { packageJson } from "ember-apply";
3
4
  import { join } from "node:path";
4
5
 
6
+ const deps = {
7
+ "@ember/test-helpers": "^5.4.3",
8
+ "@vitest/browser": "^4.1.10",
9
+ "@vitest/browser-webdriverio": "^4.1.10",
10
+ "@vitest/ui": "^4.1.10",
11
+ "ember-vitest": "^0.4.0",
12
+ vitest: "^4.1.10",
13
+ webdriverio: "^9.29.1",
14
+ };
15
+
16
+ // Apps already get these from their base. The babel plugins are resolved
17
+ // by name from the project by @nullvoxpopuli/ember-vite's no-config babel
18
+ // fallback (decorator-transforms is already a base dependency).
19
+ const libraryDeps = {
20
+ "@babel/plugin-transform-typescript": "^7.28.10",
21
+ "@nullvoxpopuli/ember-vite": "workspace:*",
22
+ "babel-plugin-ember-template-compilation": "^4.0.0",
23
+ vite: "^8.0.14",
24
+ };
25
+
26
+ const scripts = {
27
+ test: "vitest run",
28
+ "test:watch": "vitest",
29
+ "test:ui": "vitest --ui",
30
+ };
31
+
32
+ /**
33
+ * @param {import('#utils/project.js').Project} project
34
+ */
35
+ function depsFor(project) {
36
+ return {
37
+ ...deps,
38
+ ...(project.type === "library" ? libraryDeps : {}),
39
+ };
40
+ }
41
+
5
42
  /**
6
43
  * @type {import('#types').Layer}
7
44
  */
@@ -9,34 +46,11 @@ export default {
9
46
  label: "Vitest",
10
47
 
11
48
  async run(project) {
12
- // Apply test files
13
- await files.applyFolder(join(import.meta.dirname, "files"), project.directory);
14
-
15
- // Add devDependencies
16
- await packageJson.addDevDependencies(
17
- await getLatest({
18
- "@ember/test-helpers": "^5.4.1",
19
- "@ember/test-waiters": "^4.1.1",
20
- "@testing-library/dom": "^10.4.1",
21
- "ember-vitest": "^0.3.3",
22
- vitest: "^4.0.0",
23
- "@vitest/ui": "^4.0.0",
24
- "@vitest/browser": "^4.0.0",
25
- "@vitest/browser-webdriverio": "^4.0.0",
26
- webdriverio: "^9.23.2",
27
- }),
28
- project.directory,
29
- );
30
-
31
- // Add scripts
32
- await packageJson.addScripts(
33
- {
34
- test: "vitest run",
35
- "test:watch": "vitest",
36
- "test:ui": "vitest --ui",
37
- },
38
- project.directory,
39
- );
49
+ await applyFolderTo(join(import.meta.dirname, "files"), project);
50
+
51
+ await packageJson.addDevDependencies(await getLatest(depsFor(project)), project.directory);
52
+
53
+ await packageJson.addScripts(scripts, project.directory);
40
54
  },
41
55
 
42
56
  /**
@@ -52,15 +66,39 @@ export default {
52
66
  * @returns {Promise<boolean>}
53
67
  */
54
68
  async isSetup(project, explain) {
55
- const reasons = ["Vitest setup detection not implemented"];
69
+ const reasons = [];
70
+
71
+ if (!project.hasFile("vitest.config.mjs")) {
72
+ if (!explain) return false;
73
+
74
+ reasons.push("vitest.config.mjs is missing");
75
+ }
76
+
77
+ let manifest = await packageJson.read(project.directory);
78
+
79
+ for (let script of Object.keys(scripts)) {
80
+ if (!manifest.scripts?.[script]) {
81
+ if (!explain) return false;
82
+
83
+ reasons.push(`package.json is missing the "${script}" script`);
84
+ }
85
+ }
86
+
87
+ for (let dep of Object.keys(depsFor(project))) {
88
+ if (!manifest.devDependencies?.[dep]) {
89
+ if (!explain) return false;
90
+
91
+ reasons.push(`package.json is missing ${dep} in devDependencies`);
92
+ }
93
+ }
56
94
 
57
95
  if (explain) {
58
96
  return {
59
- isSetup: false,
97
+ isSetup: reasons.length === 0,
60
98
  reasons,
61
99
  };
62
100
  }
63
101
 
64
- return false;
102
+ return reasons.length === 0;
65
103
  },
66
104
  };
@@ -11,11 +11,12 @@ export async function hasConfiguredTSBabel(project) {
11
11
  /**
12
12
  * @param {import('./project.js').Project} project
13
13
  * @param {string} pluginName
14
+ * @param {string} [configPath] which babel config to inspect
14
15
  */
15
- export async function hasConfiguredPlugin(project, pluginName) {
16
+ export async function hasConfiguredPlugin(project, pluginName, configPath = "babel.config.js") {
16
17
  let hasPlugin = false;
17
18
 
18
- await js.analyze(project.path("babel.config.js"), async ({ root, j }) => {
19
+ await js.analyze(project.path(configPath), async ({ root, j }) => {
19
20
  root
20
21
  .find(j.ArrayExpression, {
21
22
  elements: {
@@ -33,9 +34,10 @@ export async function hasConfiguredPlugin(project, pluginName) {
33
34
  /**
34
35
  * @param {import('./project.js').Project} project
35
36
  * @param {string} pluginName
37
+ * @param {string} [configPath] which babel config to modify
36
38
  */
37
- export async function removeConfiguredPlugin(project, pluginName) {
38
- await js.transform(project.path("babel.config.js"), async ({ root, j }) => {
39
+ export async function removeConfiguredPlugin(project, pluginName, configPath = "babel.config.js") {
40
+ await js.transform(project.path(configPath), async ({ root, j }) => {
39
41
  root
40
42
  .find(j.ArrayExpression, {
41
43
  elements: {
@@ -56,9 +58,10 @@ export async function removeConfiguredPlugin(project, pluginName) {
56
58
  /**
57
59
  * @param {import('./project.js').Project} project
58
60
  * @param {string} plugin
61
+ * @param {string} [configPath] which babel config to modify
59
62
  */
60
- export async function prependPlugin(project, plugin) {
61
- await js.transform(project.path("babel.config.js"), async ({ root, j }) => {
63
+ export async function prependPlugin(project, plugin, configPath = "babel.config.js") {
64
+ await js.transform(project.path(configPath), async ({ root, j }) => {
62
65
  root
63
66
  .find(j.Property, {
64
67
  key: { name: "plugins" },
package/src/utils/fs.js CHANGED
@@ -3,6 +3,14 @@ import { readFile, glob, mkdir, writeFile } from "node:fs/promises";
3
3
  import { join, dirname } from "node:path";
4
4
  import { parse as parsePath } from "node:path";
5
5
  import { removeTypes } from "./remove-types.js";
6
+ import { rewriteImportsToMatchFiles } from "./rewrite-imports.js";
7
+
8
+ /**
9
+ * `**\/*` alone skips dotfiles, which bases legitimately provide
10
+ * (`.gitignore`, `.env.development`, ...): the extra patterns match
11
+ * dotfiles at any depth and files inside dot-directories (`.github/...`).
12
+ */
13
+ const EVERY_FILE = ["**/*", "**/.*", "**/.*/**/*"];
6
14
 
7
15
  /**
8
16
  *
@@ -15,7 +23,10 @@ import { removeTypes } from "./remove-types.js";
15
23
  * @param {{to: import('#utils/project.js').Project, process: (data: { entry: string, contents: string }) => string | Promise<void>}} options sub folder within the target project to copy the contents to
16
24
  */
17
25
  export async function applyFolder(from, options) {
18
- for await (const entry of glob("**/*", { exclude: ["**/node_modules", "**/dist"], cwd: from })) {
26
+ for await (const entry of glob(EVERY_FILE, {
27
+ exclude: ["**/node_modules", "**/dist"],
28
+ cwd: from,
29
+ })) {
19
30
  let sourcePath = join(from, entry);
20
31
  let targetPath = join(options.to.directory, entry);
21
32
  let directory = dirname(targetPath);
@@ -42,6 +53,19 @@ export async function applyFolder(from, options) {
42
53
  * @param {import('#utils/project.js').Project} project
43
54
  */
44
55
  export async function applyFolderTo(from, project) {
56
+ /**
57
+ * Files we wrote, to be import-rewritten in a second pass.
58
+ *
59
+ * Rewriting has to wait until every file is on disk: it checks which
60
+ * file a specifier actually resolves to, so rewriting while the folder
61
+ * is still half-applied depends on iteration order (e.g. `index.js`
62
+ * pointing at `./utils/math.ts` before `math.js` exists would be left
63
+ * alone, emitting a broken import).
64
+ *
65
+ * @type {string[]}
66
+ */
67
+ let written = [];
68
+
45
69
  await applyFolder(from, {
46
70
  to: project,
47
71
  async process({ entry, contents }) {
@@ -65,9 +89,9 @@ export async function applyFolderTo(from, project) {
65
89
  */
66
90
  return;
67
91
  }
68
- let newContents = await removeTypes(ext, contents);
69
92
 
70
- await writeFile(filePath, newContents);
93
+ await writeFile(filePath, await removeTypes(ext, contents));
94
+ written.push(filePath);
71
95
  return;
72
96
  }
73
97
  }
@@ -79,7 +103,24 @@ export async function applyFolderTo(from, project) {
79
103
  */
80
104
  return;
81
105
  }
106
+
82
107
  await writeFile(entry, contents);
108
+ written.push(entry);
83
109
  },
84
110
  });
111
+
112
+ /**
113
+ * Imports must always match the emitted files (and carry their
114
+ * extension). The util decides which files it applies to and is a
115
+ * no-op for everything else. Files that already existed are the
116
+ * user's; we leave them alone.
117
+ */
118
+ for (let filePath of written) {
119
+ let contents = await readFile(filePath, "utf-8");
120
+ let rewritten = rewriteImportsToMatchFiles(contents, filePath);
121
+
122
+ if (rewritten !== contents) {
123
+ await writeFile(filePath, rewritten);
124
+ }
125
+ }
85
126
  }
@@ -0,0 +1,196 @@
1
+ import { toTree, print, statement } from "ember-estree";
2
+
3
+ export const INSPECTOR_PACKAGE = "@embroider/legacy-inspector-support";
4
+
5
+ /**
6
+ * The `ember-source-4.12` flavor covers ember-source 4.12 and newer,
7
+ * which includes the ember-source the app base generates with.
8
+ */
9
+ export const INSPECTOR_ENTRY = `${INSPECTOR_PACKAGE}/ember-source-4.12`;
10
+
11
+ /**
12
+ * Modules whose default export is the Application (sub)class an app
13
+ * extends. The class extending one of these is where inspector support
14
+ * gets attached.
15
+ */
16
+ const APPLICATION_MODULES = new Set(["ember-strict-application-resolver", "@ember/application"]);
17
+
18
+ /** @typedef {import('ember-estree').ASTNode} ASTNode */
19
+
20
+ /**
21
+ * @param {unknown} value
22
+ * @returns {ASTNode | null}
23
+ */
24
+ function asNode(value) {
25
+ return value && typeof value === "object" ? /** @type {ASTNode} */ (value) : null;
26
+ }
27
+
28
+ /**
29
+ * @param {unknown} value
30
+ * @returns {string} an Identifier node's name ("" when it isn't one)
31
+ */
32
+ function nameOf(value) {
33
+ let node = asNode(value);
34
+
35
+ return node?.type === "Identifier" && typeof node.name === "string" ? node.name : "";
36
+ }
37
+
38
+ /**
39
+ * @param {ASTNode} importNode
40
+ * @returns {ASTNode[]}
41
+ */
42
+ function specifiersOf(importNode) {
43
+ return Array.isArray(importNode.specifiers)
44
+ ? /** @type {ASTNode[]} */ (importNode.specifiers)
45
+ : [];
46
+ }
47
+
48
+ /**
49
+ * @param {ASTNode} classNode
50
+ * @returns {ASTNode[]} the class's members
51
+ */
52
+ function classMembers(classNode) {
53
+ let members = asNode(classNode.body)?.body;
54
+
55
+ return Array.isArray(members) ? /** @type {ASTNode[]} */ (members) : [];
56
+ }
57
+
58
+ /**
59
+ * Everything wiring and detection need to know about the module: its
60
+ * import declarations, the import from {@link INSPECTOR_PACKAGE} (any
61
+ * subpath), the class extending an Application import, and that class's
62
+ * `inspector` member.
63
+ *
64
+ * @param {string} code
65
+ * @param {string} filePath
66
+ */
67
+ function analyze(code, filePath) {
68
+ /** @type {ASTNode[]} */
69
+ let imports = [];
70
+ /** @type {ASTNode[]} */
71
+ let classes = [];
72
+
73
+ // (not templateOnly, so this is always a FileNode)
74
+ let tree = /** @type {import('ember-estree').FileNode} */ (
75
+ toTree(code, {
76
+ filePath,
77
+ visitors: {
78
+ ImportDeclaration: (node) => imports.push(node),
79
+ ClassDeclaration: (node) => classes.push(node),
80
+ ClassExpression: (node) => classes.push(node),
81
+ },
82
+ })
83
+ );
84
+
85
+ /** @type {Set<string>} */
86
+ let applicationLocals = new Set();
87
+ /** @type {ASTNode | null} */
88
+ let inspectorImport = null;
89
+
90
+ for (let node of imports) {
91
+ let source = asNode(node.source);
92
+ let from = source?.type === "Literal" && typeof source.value === "string" ? source.value : "";
93
+
94
+ if (APPLICATION_MODULES.has(from)) {
95
+ for (let specifier of specifiersOf(node)) {
96
+ let local = nameOf(specifier.local);
97
+
98
+ if (local) applicationLocals.add(local);
99
+ }
100
+ }
101
+
102
+ if (from === INSPECTOR_PACKAGE || from.startsWith(`${INSPECTOR_PACKAGE}/`)) {
103
+ inspectorImport ??= node;
104
+ }
105
+ }
106
+
107
+ let applicationClass =
108
+ classes.find((node) => applicationLocals.has(nameOf(node.superClass))) ?? null;
109
+
110
+ let inspectorMember = applicationClass
111
+ ? (classMembers(applicationClass).find(
112
+ (member) => !member.static && !member.computed && nameOf(member.key) === "inspector",
113
+ ) ?? null)
114
+ : null;
115
+
116
+ return { tree, imports, inspectorImport, applicationClass, inspectorMember };
117
+ }
118
+
119
+ /**
120
+ * Whether the module already wires inspector support: it imports
121
+ * {@link INSPECTOR_PACKAGE} and its Application class carries the
122
+ * `inspector` member.
123
+ *
124
+ * @param {string} code
125
+ * @param {string} filePath
126
+ * @returns {boolean}
127
+ */
128
+ export function hasInspectorSupport(code, filePath) {
129
+ let { inspectorImport, inspectorMember } = analyze(code, filePath);
130
+
131
+ return Boolean(inspectorImport && inspectorMember);
132
+ }
133
+
134
+ /**
135
+ * Wires ember-inspector support into the module containing the app's
136
+ * Application definition, the way the official app blueprint does:
137
+ *
138
+ * ```ts
139
+ * import setupInspector from "@embroider/legacy-inspector-support/ember-source-4.12";
140
+ *
141
+ * export default class App extends Application {
142
+ * inspector = setupInspector(this);
143
+ * }
144
+ * ```
145
+ *
146
+ * The Application definition is found structurally -- any class
147
+ * (declaration or expression, named, anonymous, or assigned to a
148
+ * variable) extending a binding imported from an Application-providing
149
+ * module -- so user-restructured app files are wired the same as
150
+ * freshly generated ones.
151
+ *
152
+ * Idempotent: already-wired (or partially wired) modules keep their
153
+ * existing import name and member. Returns the code unchanged when
154
+ * there is nothing to do -- including when no Application definition
155
+ * can be found.
156
+ *
157
+ * @param {string} code
158
+ * @param {string} filePath
159
+ * @returns {string}
160
+ */
161
+ export function wireInspectorSupport(code, filePath) {
162
+ let { tree, imports, inspectorImport, applicationClass, inspectorMember } = analyze(
163
+ code,
164
+ filePath,
165
+ );
166
+
167
+ if (!applicationClass) return code;
168
+ if (inspectorImport && inspectorMember) return code;
169
+
170
+ let localName = "setupInspector";
171
+
172
+ if (inspectorImport) {
173
+ let defaultSpecifier = specifiersOf(inspectorImport).find(
174
+ (specifier) => specifier.type === "ImportDefaultSpecifier",
175
+ );
176
+
177
+ localName = nameOf(defaultSpecifier?.local);
178
+
179
+ // an import that binds no default gives the member nothing to call
180
+ if (!localName) return code;
181
+ } else {
182
+ let body = /** @type {ASTNode[]} */ (tree.program.body);
183
+ let lastImport = imports.at(-1);
184
+ let insertAt = lastImport ? body.indexOf(lastImport) + 1 : 0;
185
+
186
+ body.splice(insertAt, 0, statement`import ${localName} from "${INSPECTOR_ENTRY}";`);
187
+ }
188
+
189
+ if (!inspectorMember) {
190
+ let [member] = classMembers(statement`class _ { inspector = ${localName}(this); }`);
191
+
192
+ if (member) classMembers(applicationClass).push(member);
193
+ }
194
+
195
+ return print(tree);
196
+ }
package/src/utils/npm.js CHANGED
@@ -7,6 +7,18 @@ import { join, resolve } from "node:path";
7
7
  */
8
8
  const CACHE = {};
9
9
 
10
+ /**
11
+ * Local, not-yet-published workspace packages that must be `link:`ed rather
12
+ * than resolved from the registry. Maps the published package name to its
13
+ * folder under `packages/`.
14
+ *
15
+ * @type {{ [name: string]: string }}
16
+ */
17
+ const LOCAL_PACKAGES = {
18
+ "@nullvoxpopuli/ember-vite": "vite",
19
+ "@nullvoxpopuli/ember-rolldown": "rolldown",
20
+ };
21
+
10
22
  /**
11
23
  * @param {{ [name: string]: string }} deps map of dep name to semver range
12
24
  */
@@ -25,18 +37,20 @@ export async function getLatest(deps) {
25
37
 
26
38
  /**
27
39
  * HACK FOR CI.
28
- * In practice, this package will be published separately, and that version will be used.
29
- * Only the local, not-yet-published @nullvoxpopuli/ember-vite package needs the link;
30
- * every other dependency must still resolve its real version.
40
+ * In practice, these packages will be published separately, and those
41
+ * versions will be used. Only the local, not-yet-published
42
+ * @nullvoxpopuli/ember-* packages need the link; every other dependency
43
+ * must still resolve its real version.
31
44
  *
32
45
  * We use `link:` (a symlink) rather than `file:` (a copy into the store)
33
- * on purpose: the package ships TypeScript source, and Node 24 refuses to
46
+ * on purpose: the packages ship TypeScript source, and Node 24 refuses to
34
47
  * strip types for files physically located under node_modules. A symlink
35
- * makes Node resolve the realpath to packages/vite (outside node_modules),
48
+ * makes Node resolve the realpath to packages/* (outside node_modules),
36
49
  * so its `.ts` runs directly.
37
50
  */
38
- if (needsLocalLink && dep === "@nullvoxpopuli/ember-vite") {
39
- version = "link:" + resolve(join(import.meta.dirname, "../../packages/vite"));
51
+ if (needsLocalLink && LOCAL_PACKAGES[dep]) {
52
+ version =
53
+ "link:" + resolve(join(import.meta.dirname, "../../packages", LOCAL_PACKAGES[dep]));
40
54
  } else {
41
55
  if (range == "workspace:*") {
42
56
  range = "latest";
@@ -9,6 +9,11 @@ import { substringBytes } from "./buffer.js";
9
9
  * @returns
10
10
  */
11
11
  export async function removeTypes(extension, code) {
12
+ // Nothing to transform (babel-remove-types throws on empty input)
13
+ if (code.trim() === "") {
14
+ return code;
15
+ }
16
+
12
17
  if (extension === ".gts") {
13
18
  return await wrappedRemoveTypes(code, babelRemoveTypes);
14
19
  }
@@ -0,0 +1,157 @@
1
+ import fs from "node:fs";
2
+ import { dirname, extname } from "node:path";
3
+ import { toTree, print } from "ember-estree";
4
+ import enhancedResolve from "enhanced-resolve";
5
+
6
+ const { ResolverFactory, CachedInputFileSystem } = enhancedResolve;
7
+
8
+ /**
9
+ * The module files this util operates on. Doubles as the candidate
10
+ * extensions the resolver may append when a request has none (or when we
11
+ * retry a request without its original extension) -- the single source
12
+ * of truth for both.
13
+ */
14
+ const MODULE_EXTENSIONS = [".js", ".gjs", ".ts", ".gts"];
15
+
16
+ function createResolver() {
17
+ return ResolverFactory.createResolver({
18
+ // short-lived cache: files are being written while generation runs
19
+ fileSystem: new CachedInputFileSystem(fs, 100),
20
+ useSyncFileSystemCalls: true,
21
+ extensions: MODULE_EXTENSIONS,
22
+ // no conditionNames: we only resolve local paths and sub-path
23
+ // self-imports (whose targets in the emitted `imports` field are
24
+ // plain strings, which match regardless of conditions)
25
+ });
26
+ }
27
+
28
+ /**
29
+ * Imports must match files. Generation renames files (e.g. type removal
30
+ * turns .ts into .js), so for every local specifier (./, ../, and #
31
+ * subpath-imports -- expanded against the project's package.json
32
+ * `imports` field) we check what actually exists in the emitted project
33
+ * tree:
34
+ *
35
+ * - the specifier resolves to a file and carries an extension? leave it
36
+ * alone.
37
+ * - it resolves but has no extension? append the resolved file's
38
+ * extension (fully-specified local imports resolve faster), unless the
39
+ * fully-specified form no longer maps to the same file (e.g. `#config`,
40
+ * whose `imports` target carries the extension itself).
41
+ * - it doesn't resolve, but the same specifier with whatever extension
42
+ * IS on disk does? rewrite it to that.
43
+ * - nothing matches? leave it alone -- we can't know better, and the
44
+ * project's own build will report it loudly.
45
+ *
46
+ * Non-module files are returned untouched.
47
+ *
48
+ * ember-estree parses gjs natively and its visitors fire on actual module
49
+ * specifiers only (static imports, re-exports, and dynamic import()) --
50
+ * matched specifiers are rewritten on the AST and the File is printed
51
+ * back out (comments included). Formatting is the project's own concern:
52
+ * new projects run their configured lint:fix / format right away (it's
53
+ * in the CLI's "Next steps").
54
+ *
55
+ * Files with no specifiers to rewrite are returned untouched.
56
+ *
57
+ * @param {string} code the source as it will be emitted
58
+ * @param {string} emittedPath the path the file will be written to
59
+ * @returns {string}
60
+ */
61
+ export function rewriteImportsToMatchFiles(code, emittedPath) {
62
+ if (!MODULE_EXTENSIONS.includes(extname(emittedPath))) return code;
63
+
64
+ let issuerDirectory = dirname(emittedPath);
65
+ let resolver = createResolver();
66
+
67
+ /**
68
+ * @param {string} specifier
69
+ * @returns {string | null} the resolved file path, if any
70
+ */
71
+ function resolveFrom(specifier) {
72
+ try {
73
+ return resolver.resolveSync({}, issuerDirectory, specifier) || null;
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ let changed = false;
80
+
81
+ /**
82
+ * @param {unknown} source
83
+ */
84
+ function consider(source) {
85
+ if (!source || typeof source !== "object") return;
86
+
87
+ let node = /** @type {import('ember-estree').ASTNode} */ (source);
88
+
89
+ if (node.type !== "Literal" || typeof node.value !== "string") return;
90
+
91
+ let specifier = node.value;
92
+ let isLocal =
93
+ specifier.startsWith("./") || specifier.startsWith("../") || specifier.startsWith("#");
94
+
95
+ if (!isLocal) return;
96
+
97
+ let currentExtension = extname(specifier);
98
+ let resolved = resolveFrom(specifier);
99
+
100
+ if (resolved) {
101
+ // fully specified and correct
102
+ if (currentExtension) return;
103
+
104
+ // extensionless: append the resolved file's extension, as long as
105
+ // the fully-specified form still maps to the same file (`#config`
106
+ // maps with the extension in the target, so `#config.js` wouldn't)
107
+ let candidate = specifier + extname(resolved);
108
+
109
+ if (resolveFrom(candidate) !== resolved) return;
110
+
111
+ rewrite(node, candidate);
112
+ return;
113
+ }
114
+
115
+ if (!currentExtension) return;
116
+
117
+ // check which file actually exists for this specifier
118
+ let base = specifier.slice(0, -currentExtension.length);
119
+ let resolvedBase = resolveFrom(base);
120
+
121
+ if (!resolvedBase) return;
122
+
123
+ let replacement = base + extname(resolvedBase);
124
+
125
+ if (replacement === specifier) return;
126
+
127
+ rewrite(node, replacement);
128
+ }
129
+
130
+ /**
131
+ * @param {import('ember-estree').ASTNode} node
132
+ * @param {string} replacement
133
+ */
134
+ function rewrite(node, replacement) {
135
+ node.value = replacement;
136
+ node.raw = JSON.stringify(replacement);
137
+ changed = true;
138
+ }
139
+
140
+ // (not templateOnly, so this is always a FileNode)
141
+ let tree = /** @type {import('ember-estree').FileNode} */ (
142
+ toTree(code, {
143
+ filePath: emittedPath,
144
+ visitors: {
145
+ ImportDeclaration: (node) => consider(node.source),
146
+ ExportNamedDeclaration: (node) => consider(node.source),
147
+ ExportAllDeclaration: (node) => consider(node.source),
148
+ // dynamic import()
149
+ ImportExpression: (node) => consider(node.source),
150
+ },
151
+ })
152
+ );
153
+
154
+ if (!changed) return code;
155
+
156
+ return print(tree);
157
+ }