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.
- package/.github/workflows/ci.yml +17 -2
- package/.release-plan.json +84 -4
- package/CHANGELOG.md +68 -0
- package/README.md +29 -0
- package/package.json +4 -2
- package/packages/build-tooling-utils/README.md +37 -0
- package/packages/build-tooling-utils/index.ts +2 -0
- package/packages/build-tooling-utils/package.json +42 -0
- package/packages/build-tooling-utils/rolldown.config.js +18 -0
- package/packages/build-tooling-utils/src/extensions.ts +18 -0
- package/packages/build-tooling-utils/src/maybe-babel.ts +146 -0
- package/packages/rolldown/README.md +115 -0
- package/packages/rolldown/index.ts +56 -0
- package/packages/rolldown/package.json +59 -0
- package/packages/rolldown/rolldown.config.js +21 -0
- package/packages/rolldown/src/babel.ts +136 -0
- package/packages/rolldown/src/define-config.ts +32 -0
- package/packages/rolldown/src/externals.ts +83 -0
- package/packages/rolldown/src/isolated-declarations.ts +65 -0
- package/packages/rolldown/src/transform.ts +162 -0
- package/packages/vite/README.md +7 -21
- package/packages/vite/index.ts +1 -1
- package/packages/vite/package.json +4 -6
- package/packages/vite/src/ember.ts +129 -15
- package/packages/vite/src/test-html.ts +86 -0
- package/src/bases/minimal-app/files/app/router.ts +1 -0
- package/src/bases/minimal-app/files/tsconfig.json +1 -0
- package/src/bases/minimal-app/index.js +2 -2
- package/src/bases/minimal-library/files/README.md +41 -0
- package/src/bases/minimal-library/files/package.json +49 -0
- package/src/bases/minimal-library/files/tsconfig.json +10 -0
- package/src/bases/minimal-library/files/tsdown.config.js +6 -0
- package/src/bases/minimal-library/index.js +158 -2
- package/src/cli/args.js +7 -1
- package/src/cli/index.js +90 -16
- package/src/cli/questions/layers.js +5 -2
- package/src/cli/questions/project-type.js +0 -5
- package/src/cli/questions/write.js +169 -0
- package/src/index.js +1 -0
- package/src/layers/are-the-types-wrong/index.js +74 -0
- package/src/layers/git/files/gitignore +14 -1
- package/src/layers/git/index.js +6 -5
- package/src/layers/github-actions/index.js +12 -8
- package/src/layers/index.js +1 -1
- package/src/layers/inspector-support/index.js +101 -0
- package/src/layers/prettier/files/.prettierignore +4 -0
- package/src/layers/publint/index.js +68 -0
- package/src/layers/qunit/files/tests/unit/macros-test.ts +2 -2
- package/src/layers/qunit/index.js +107 -7
- package/src/layers/qunit/library-files/config/test/babel.config.js +41 -0
- package/src/layers/qunit/library-files/config/test/testem.cjs +26 -0
- package/src/layers/qunit/library-files/tests/test-helper.ts +36 -0
- package/src/layers/qunit/library-files/tests/unit/.gitkeep +0 -0
- package/src/layers/qunit/library-files/vite.config.mjs +6 -0
- package/src/layers/typescript/index.js +43 -7
- package/src/layers/vitest/files/vitest.config.mjs +11 -11
- package/src/layers/vitest/index.js +70 -32
- package/src/utils/babel.js +9 -6
- package/src/utils/fs.js +44 -3
- package/src/utils/inspector-support.js +196 -0
- package/src/utils/npm.js +21 -7
- package/src/utils/remove-types.js +5 -0
- package/src/utils/rewrite-imports.js +157 -0
- package/src/utils/stage.js +317 -0
- package/src/utils/tsdown-config.js +85 -0
- package/src/utils/types.ts +5 -1
- package/test/ember.nvp/base-library.test.ts +232 -0
- package/test/ember.nvp/generator-replace-or-update.test.ts +2 -3
- package/test/ember.nvp/inspector-support.test.ts +179 -0
- package/test/ember.nvp/layer-inspector-support.test.ts +69 -0
- package/test/ember.nvp/layer-publish-checks.test.ts +103 -0
- package/test/ember.nvp/layer-qunit.test.ts +376 -15
- package/test/ember.nvp/layer-vitest.test.ts +250 -0
- package/test/ember.nvp/layers.test.ts +16 -0
- package/test/ember.nvp/library-src-fixtures.ts +81 -0
- package/test/ember.nvp/permutations/app-1.test.ts +3 -0
- package/test/ember.nvp/permutations/app-2.test.ts +3 -0
- package/test/ember.nvp/permutations/library-1.test.ts +3 -0
- package/test/ember.nvp/permutations/library-2.test.ts +3 -0
- package/test/ember.nvp/permutations/shared.ts +239 -0
- package/test/ember.nvp/rewrite-imports.test.ts +235 -0
- package/test/ember.nvp/stage.test.ts +188 -0
- package/test/test-helpers/permutate.test.ts +17 -0
- package/test/test-helpers.ts +20 -7
- package/tsconfig.json +1 -0
- package/packages/vite/src/maybe-babel.ts +0 -219
- package/src/layers/qunit/files/run-tests.mjs +0 -30
- package/src/layers/vitest/files/tests/test-helper.ts +0 -5
- package/test/ember.nvp/permutations.test.ts +0 -200
- /package/src/{layers/vitest/files/tests/integration/.gitkeep → bases/minimal-library/files/src/index.ts} +0 -0
- /package/src/layers/{vitest/files/tests/unit → qunit/library-files/tests/rendering}/.gitkeep +0 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { cp, mkdir, mkdtemp, readdir, readFile, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { basename, dirname, join, relative, sep } from "node:path";
|
|
5
|
+
import { styleText } from "node:util";
|
|
6
|
+
import { structuredPatch } from "diff";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Directories that belong to the target project's environment rather than
|
|
10
|
+
* its generated contents. They are never seeded into the stage, never
|
|
11
|
+
* diffed, and never deleted on commit.
|
|
12
|
+
*
|
|
13
|
+
* (`.git` has one exception: see {@link Stage#commit})
|
|
14
|
+
*/
|
|
15
|
+
const ENVIRONMENT_DIRECTORIES = new Set(["node_modules", ".git"]);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {'added' | 'modified' | 'deleted'} ChangeStatus
|
|
19
|
+
* @typedef {{ path: string, status: ChangeStatus }} Change
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A copy-on-write overlay for a project directory, in the spirit of
|
|
24
|
+
* Docker's layered file systems:
|
|
25
|
+
*
|
|
26
|
+
* - the target directory is the read-only lower layer
|
|
27
|
+
* - a real directory in the OS temp dir is the writable upper layer,
|
|
28
|
+
* seeded ("copied up") from the target
|
|
29
|
+
*
|
|
30
|
+
* All generation (bases, layers, consolidators) runs against the upper
|
|
31
|
+
* layer via `stage.directory`. Because the upper layer is a real
|
|
32
|
+
* directory, layers keep using `node:fs`, `ember-apply`, and even
|
|
33
|
+
* subprocesses (`git`, package managers) exactly as they would against
|
|
34
|
+
* the real project -- an in-process virtual fs can't offer that, since
|
|
35
|
+
* child processes can't see it.
|
|
36
|
+
*
|
|
37
|
+
* Nothing touches the target until {@link Stage#commit}; {@link Stage#discard}
|
|
38
|
+
* throws the upper layer away.
|
|
39
|
+
*/
|
|
40
|
+
export class Stage {
|
|
41
|
+
/**
|
|
42
|
+
* @param {string} targetDirectory the directory the project should end up in
|
|
43
|
+
* @param {{ seed?: boolean }} [options]
|
|
44
|
+
* seed: copy the target's current contents into the stage first
|
|
45
|
+
* (a "copy up"). Defaults to true. Pass false to stage a from-scratch
|
|
46
|
+
* generation (e.g. the "replace" flow).
|
|
47
|
+
* @returns {Promise<Stage>}
|
|
48
|
+
*/
|
|
49
|
+
static async create(targetDirectory, options = {}) {
|
|
50
|
+
const { seed = true } = options;
|
|
51
|
+
const directory = await mkdtemp(join(tmpdir(), "ember.nvp-stage-"));
|
|
52
|
+
const exists = existsSync(targetDirectory);
|
|
53
|
+
|
|
54
|
+
if (exists && seed) {
|
|
55
|
+
await cp(targetDirectory, directory, {
|
|
56
|
+
recursive: true,
|
|
57
|
+
force: true,
|
|
58
|
+
filter: (source) => !ENVIRONMENT_DIRECTORIES.has(basename(source)),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return new Stage(targetDirectory, directory, { isNew: !exists });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
#target;
|
|
66
|
+
#directory;
|
|
67
|
+
#isNew;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Use {@link Stage.create} instead.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} target
|
|
73
|
+
* @param {string} directory
|
|
74
|
+
* @param {{ isNew: boolean }} info
|
|
75
|
+
*/
|
|
76
|
+
constructor(target, directory, info) {
|
|
77
|
+
this.#target = target;
|
|
78
|
+
this.#directory = directory;
|
|
79
|
+
this.#isNew = info.isNew;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The writable upper layer. Generate in here,
|
|
84
|
+
* e.g. `new Project(stage.directory, desires)`.
|
|
85
|
+
*
|
|
86
|
+
* @type {string}
|
|
87
|
+
*/
|
|
88
|
+
get directory() {
|
|
89
|
+
return this.#directory;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The directory a commit writes to.
|
|
94
|
+
*
|
|
95
|
+
* @type {string}
|
|
96
|
+
*/
|
|
97
|
+
get targetDirectory() {
|
|
98
|
+
return this.#target;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Whether the target directory existed when the stage was created.
|
|
103
|
+
*
|
|
104
|
+
* @type {boolean}
|
|
105
|
+
*/
|
|
106
|
+
get isNew() {
|
|
107
|
+
return this.#isNew;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Compares the upper layer to the target.
|
|
112
|
+
*
|
|
113
|
+
* @returns {Promise<Change[]>}
|
|
114
|
+
*/
|
|
115
|
+
async changes() {
|
|
116
|
+
const staged = await listFiles(this.#directory);
|
|
117
|
+
const existing = existsSync(this.#target) ? await listFiles(this.#target) : new Set();
|
|
118
|
+
|
|
119
|
+
/** @type {Change[]} */
|
|
120
|
+
const changes = [];
|
|
121
|
+
|
|
122
|
+
for (const path of staged) {
|
|
123
|
+
if (!existing.has(path)) {
|
|
124
|
+
changes.push({ path, status: "added" });
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const [before, after] = await Promise.all([
|
|
129
|
+
readFile(join(this.#target, path)),
|
|
130
|
+
readFile(join(this.#directory, path)),
|
|
131
|
+
]);
|
|
132
|
+
|
|
133
|
+
if (!before.equals(after)) {
|
|
134
|
+
changes.push({ path, status: "modified" });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
for (const path of existing) {
|
|
139
|
+
if (!staged.has(path)) {
|
|
140
|
+
changes.push({ path, status: "deleted" });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return changes.sort((a, b) => a.path.localeCompare(b.path));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* A unified diff of the staged changes against the target,
|
|
149
|
+
* colorized when stdout supports it.
|
|
150
|
+
*
|
|
151
|
+
* @param {Change[]} [changes] pass the result of {@link Stage#changes} to avoid recomputing
|
|
152
|
+
* @returns {Promise<string>}
|
|
153
|
+
*/
|
|
154
|
+
async diff(changes) {
|
|
155
|
+
changes ??= await this.changes();
|
|
156
|
+
|
|
157
|
+
const patches = [];
|
|
158
|
+
|
|
159
|
+
for (const change of changes) {
|
|
160
|
+
const before =
|
|
161
|
+
change.status === "added" ? null : await readFile(join(this.#target, change.path));
|
|
162
|
+
const after =
|
|
163
|
+
change.status === "deleted" ? null : await readFile(join(this.#directory, change.path));
|
|
164
|
+
|
|
165
|
+
patches.push(renderPatch(change, before, after));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return patches.join("\n");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Applies the staged changes to the target directory, then removes the
|
|
173
|
+
* stage. Only paths reported by {@link Stage#changes} are touched --
|
|
174
|
+
* everything else in the target (untracked files, node_modules, .git)
|
|
175
|
+
* is left alone.
|
|
176
|
+
*
|
|
177
|
+
* One exception: when the stage has a `.git` directory and the target
|
|
178
|
+
* does not (the git layer ran `git init` inside the stage), the `.git`
|
|
179
|
+
* directory is carried over so a freshly generated project keeps its
|
|
180
|
+
* history.
|
|
181
|
+
*
|
|
182
|
+
* @param {Change[]} [changes] the changes to apply -- pass a subset of
|
|
183
|
+
* {@link Stage#changes} to apply only some of them (e.g. the ones a
|
|
184
|
+
* user accepted during review). Defaults to all staged changes.
|
|
185
|
+
* @returns {Promise<Change[]>} the changes that were written
|
|
186
|
+
*/
|
|
187
|
+
async commit(changes) {
|
|
188
|
+
changes ??= await this.changes();
|
|
189
|
+
|
|
190
|
+
await mkdir(this.#target, { recursive: true });
|
|
191
|
+
|
|
192
|
+
for (const change of changes) {
|
|
193
|
+
const targetPath = join(this.#target, change.path);
|
|
194
|
+
|
|
195
|
+
if (change.status === "deleted") {
|
|
196
|
+
await rm(targetPath, { force: true });
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
await mkdir(dirname(targetPath), { recursive: true });
|
|
201
|
+
await cp(join(this.#directory, change.path), targetPath, { force: true });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const stagedGit = join(this.#directory, ".git");
|
|
205
|
+
const targetGit = join(this.#target, ".git");
|
|
206
|
+
|
|
207
|
+
if (existsSync(stagedGit) && !existsSync(targetGit)) {
|
|
208
|
+
await cp(stagedGit, targetGit, { recursive: true });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
await this.discard();
|
|
212
|
+
|
|
213
|
+
return changes;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Removes the stage without writing anything to the target.
|
|
218
|
+
*/
|
|
219
|
+
async discard() {
|
|
220
|
+
await rm(this.#directory, { recursive: true, force: true });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* All files (as paths relative to `directory`), excluding environment
|
|
226
|
+
* directories. Includes dotfiles (`.gitignore`, `.github/`, ...), which
|
|
227
|
+
* `fs.glob` would skip.
|
|
228
|
+
*
|
|
229
|
+
* @param {string} directory
|
|
230
|
+
* @returns {Promise<Set<string>>}
|
|
231
|
+
*/
|
|
232
|
+
async function listFiles(directory) {
|
|
233
|
+
/** @type {Set<string>} */
|
|
234
|
+
const files = new Set();
|
|
235
|
+
|
|
236
|
+
const entries = await readdir(directory, { withFileTypes: true, recursive: true });
|
|
237
|
+
|
|
238
|
+
for (const entry of entries) {
|
|
239
|
+
if (!entry.isFile()) continue;
|
|
240
|
+
|
|
241
|
+
const path = relative(directory, join(entry.parentPath, entry.name));
|
|
242
|
+
|
|
243
|
+
if (path.split(sep).some((segment) => ENVIRONMENT_DIRECTORIES.has(segment))) {
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
files.add(path);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return files;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* @param {Change} change
|
|
255
|
+
* @param {Buffer | null} before
|
|
256
|
+
* @param {Buffer | null} after
|
|
257
|
+
* @returns {string}
|
|
258
|
+
*/
|
|
259
|
+
function renderPatch(change, before, after) {
|
|
260
|
+
const header = styleText("bold", `${statusLabel(change.status)} ${change.path}`);
|
|
261
|
+
|
|
262
|
+
if (before?.includes(0) || after?.includes(0)) {
|
|
263
|
+
return `${header}\n${styleText("dim", "(binary file)")}\n`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const patch = structuredPatch(
|
|
267
|
+
change.path,
|
|
268
|
+
change.path,
|
|
269
|
+
before?.toString() ?? "",
|
|
270
|
+
after?.toString() ?? "",
|
|
271
|
+
undefined,
|
|
272
|
+
undefined,
|
|
273
|
+
{ context: 3 },
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
const lines = [
|
|
277
|
+
header,
|
|
278
|
+
styleText("red", `--- ${change.status === "added" ? "/dev/null" : `a/${change.path}`}`),
|
|
279
|
+
styleText("green", `+++ ${change.status === "deleted" ? "/dev/null" : `b/${change.path}`}`),
|
|
280
|
+
];
|
|
281
|
+
|
|
282
|
+
for (const hunk of patch.hunks) {
|
|
283
|
+
lines.push(
|
|
284
|
+
styleText(
|
|
285
|
+
"cyan",
|
|
286
|
+
`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`,
|
|
287
|
+
),
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
for (const line of hunk.lines) {
|
|
291
|
+
if (line.startsWith("+")) {
|
|
292
|
+
lines.push(styleText("green", line));
|
|
293
|
+
} else if (line.startsWith("-")) {
|
|
294
|
+
lines.push(styleText("red", line));
|
|
295
|
+
} else {
|
|
296
|
+
lines.push(line);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return lines.join("\n") + "\n";
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* @param {ChangeStatus} status
|
|
306
|
+
* @returns {string}
|
|
307
|
+
*/
|
|
308
|
+
function statusLabel(status) {
|
|
309
|
+
switch (status) {
|
|
310
|
+
case "added":
|
|
311
|
+
return styleText("green", "A");
|
|
312
|
+
case "modified":
|
|
313
|
+
return styleText("yellow", "M");
|
|
314
|
+
case "deleted":
|
|
315
|
+
return styleText("red", "D");
|
|
316
|
+
}
|
|
317
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { toTree, print, statements } from "ember-estree";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Adds a property to the object passed to `defineConfig(...)` in the
|
|
6
|
+
* project's tsdown.config.js.
|
|
7
|
+
*
|
|
8
|
+
* No-ops when the property is already present (layers re-run), and when
|
|
9
|
+
* the config doesn't have the `defineConfig({ ... })` shape -- an existing
|
|
10
|
+
* project's hand-rolled config is the user's to manage.
|
|
11
|
+
*
|
|
12
|
+
* @param {import('#utils/project.js').Project} project
|
|
13
|
+
* @param {string} propertyName
|
|
14
|
+
* @param {string} propertySource the property as source, e.g. `publint: true`
|
|
15
|
+
*/
|
|
16
|
+
export async function addTsdownConfigProperty(project, propertyName, propertySource) {
|
|
17
|
+
const configPath = project.path("tsdown.config.js");
|
|
18
|
+
const contents = await readFile(configPath, "utf-8");
|
|
19
|
+
|
|
20
|
+
/** @type {import('ember-estree').ASTNode | null} */
|
|
21
|
+
let configObject = null;
|
|
22
|
+
|
|
23
|
+
const tree = /** @type {import('ember-estree').FileNode} */ (
|
|
24
|
+
toTree(contents, {
|
|
25
|
+
filePath: configPath,
|
|
26
|
+
visitors: {
|
|
27
|
+
CallExpression(node) {
|
|
28
|
+
const callee = /** @type {import('ember-estree').ASTNode} */ (node.callee);
|
|
29
|
+
|
|
30
|
+
if (callee?.type !== "Identifier" || callee.name !== "defineConfig") return;
|
|
31
|
+
|
|
32
|
+
const [argument] = /** @type {import('ember-estree').ASTNode[]} */ (node.arguments ?? []);
|
|
33
|
+
|
|
34
|
+
if (argument?.type === "ObjectExpression") {
|
|
35
|
+
configObject = argument;
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
if (!configObject) return;
|
|
43
|
+
|
|
44
|
+
const found = /** @type {import('ember-estree').ASTNode} */ (configObject);
|
|
45
|
+
const properties = /** @type {import('ember-estree').ASTNode[]} */ (found.properties ?? []);
|
|
46
|
+
|
|
47
|
+
const alreadyPresent = properties.some((property) => {
|
|
48
|
+
const key = /** @type {import('ember-estree').ASTNode} */ (property.key);
|
|
49
|
+
|
|
50
|
+
return key?.type === "Identifier" && key.name === propertyName;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
if (alreadyPresent) return;
|
|
54
|
+
|
|
55
|
+
const [statement] = statements`defineConfig({ ${propertySource} });`;
|
|
56
|
+
|
|
57
|
+
if (!statement) return;
|
|
58
|
+
|
|
59
|
+
const expression = /** @type {import('ember-estree').ASTNode} */ (statement.expression);
|
|
60
|
+
const [templateObject] = /** @type {import('ember-estree').ASTNode[]} */ (expression.arguments);
|
|
61
|
+
const [property] = /** @type {import('ember-estree').ASTNode[]} */ (
|
|
62
|
+
templateObject?.properties ?? []
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
if (!property) return;
|
|
66
|
+
|
|
67
|
+
properties.push(property);
|
|
68
|
+
found.properties = properties;
|
|
69
|
+
|
|
70
|
+
await writeFile(configPath, print(tree));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Whether the project's tsdown.config.js configures `propertyName`.
|
|
75
|
+
*
|
|
76
|
+
* @param {import('#utils/project.js').Project} project
|
|
77
|
+
* @param {string} propertyName
|
|
78
|
+
*/
|
|
79
|
+
export async function hasTsdownConfigProperty(project, propertyName) {
|
|
80
|
+
if (!project.hasFile("tsdown.config.js")) return false;
|
|
81
|
+
|
|
82
|
+
const contents = await readFile(project.path("tsdown.config.js"), "utf-8");
|
|
83
|
+
|
|
84
|
+
return contents.includes(propertyName);
|
|
85
|
+
}
|
package/src/utils/types.ts
CHANGED
|
@@ -8,7 +8,11 @@ export interface Layer {
|
|
|
8
8
|
*/
|
|
9
9
|
label: string;
|
|
10
10
|
hint?: string;
|
|
11
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Whether the layer is pre-selected in the CLI (the user can still
|
|
13
|
+
* deselect it).
|
|
14
|
+
*/
|
|
15
|
+
defaultValue?: (projectType: ProjectType) => unknown;
|
|
12
16
|
/**
|
|
13
17
|
* The function that applies the codemod
|
|
14
18
|
*
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { describe, it, beforeAll, afterAll, expect } from "vitest";
|
|
2
|
+
import { generate, mktemp } from "#test-helpers";
|
|
3
|
+
import { writeLibrarySource } from "./library-src-fixtures.ts";
|
|
4
|
+
import { execa } from "execa";
|
|
5
|
+
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
6
|
+
import { join, relative, sep } from "node:path";
|
|
7
|
+
|
|
8
|
+
import type { Project } from "ember.nvp";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The minimal-library base has to produce a *buildable* library: these tests
|
|
12
|
+
* generate both flavors (JavaScript and TypeScript), snapshot the interesting
|
|
13
|
+
* generated files, and run the real `pnpm build` (tsdown + rolldown) against
|
|
14
|
+
* them.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
async function listFiles(directory: string): Promise<string[]> {
|
|
18
|
+
const entries = await readdir(directory, { withFileTypes: true, recursive: true });
|
|
19
|
+
|
|
20
|
+
return entries
|
|
21
|
+
.filter((entry) => entry.isFile())
|
|
22
|
+
.map((entry) => relative(directory, join(entry.parentPath, entry.name)))
|
|
23
|
+
.filter((path) => !path.split(sep).includes("node_modules"))
|
|
24
|
+
.sort();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function read(project: Project, filePath: string): Promise<string> {
|
|
28
|
+
return readFile(join(project.directory, filePath), "utf-8");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function installAndBuild(project: Project) {
|
|
32
|
+
let install = await execa("pnpm install", { cwd: project.directory, shell: true });
|
|
33
|
+
expect(install.exitCode).toBe(0);
|
|
34
|
+
|
|
35
|
+
let build = await execa("pnpm build", { cwd: project.directory, shell: true });
|
|
36
|
+
expect(build.exitCode).toBe(0);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("base: minimal-library", () => {
|
|
40
|
+
const dirs: string[] = [];
|
|
41
|
+
|
|
42
|
+
afterAll(async () => {
|
|
43
|
+
if (process.env.CI) return;
|
|
44
|
+
|
|
45
|
+
for (const dir of dirs) {
|
|
46
|
+
await rm(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("JavaScript", () => {
|
|
51
|
+
let project: Project;
|
|
52
|
+
|
|
53
|
+
beforeAll(async () => {
|
|
54
|
+
project = await generate({ type: "library", name: "my-lib", layers: [] });
|
|
55
|
+
dirs.push(project.directory);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("generates the expected files (src is empty)", async () => {
|
|
59
|
+
expect(await listFiles(project.directory)).toMatchInlineSnapshot(`
|
|
60
|
+
[
|
|
61
|
+
".gitignore",
|
|
62
|
+
"README.md",
|
|
63
|
+
"package.json",
|
|
64
|
+
"src/index.js",
|
|
65
|
+
"tsdown.config.js",
|
|
66
|
+
]
|
|
67
|
+
`);
|
|
68
|
+
|
|
69
|
+
expect(await read(project, "src/index.js")).toBe("");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("is publishable", async () => {
|
|
73
|
+
let manifest = JSON.parse(await read(project, "package.json"));
|
|
74
|
+
|
|
75
|
+
// The template is private (it lives in a workspace); the generated
|
|
76
|
+
// library must not be
|
|
77
|
+
expect(manifest).not.toHaveProperty("private");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("has no TypeScript leftovers", async () => {
|
|
81
|
+
let manifest = JSON.parse(await read(project, "package.json"));
|
|
82
|
+
|
|
83
|
+
expect(manifest.devDependencies).not.toHaveProperty("typescript");
|
|
84
|
+
expect(manifest.devDependencies).not.toHaveProperty("@ember/library-tsconfig");
|
|
85
|
+
// No declarations are emitted without types
|
|
86
|
+
expect(JSON.stringify(manifest.exports)).not.toContain("types");
|
|
87
|
+
|
|
88
|
+
expect(await read(project, "tsdown.config.js")).toMatchInlineSnapshot(`
|
|
89
|
+
"import { defineConfig, ember } from "@nullvoxpopuli/ember-rolldown";
|
|
90
|
+
|
|
91
|
+
export default defineConfig({
|
|
92
|
+
entry: ["./src/index.js"],
|
|
93
|
+
dts: false,
|
|
94
|
+
plugins: [ember()],
|
|
95
|
+
});
|
|
96
|
+
"
|
|
97
|
+
`);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("removes the TS plugin from an existing project's babel config", async () => {
|
|
101
|
+
// The base doesn't emit a babel.config.js, but generation can run
|
|
102
|
+
// over an existing project that has one
|
|
103
|
+
let directory = await mktemp("existing-lib");
|
|
104
|
+
|
|
105
|
+
dirs.push(directory);
|
|
106
|
+
|
|
107
|
+
await mkdir(directory, { recursive: true });
|
|
108
|
+
await writeFile(
|
|
109
|
+
join(directory, "babel.config.js"),
|
|
110
|
+
`export default {
|
|
111
|
+
plugins: [
|
|
112
|
+
["@babel/plugin-transform-typescript", { allExtensions: true }],
|
|
113
|
+
["module:decorator-transforms"],
|
|
114
|
+
],
|
|
115
|
+
};
|
|
116
|
+
`,
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
let existing = await generate({ directory, type: "library", name: "existing-lib" });
|
|
120
|
+
|
|
121
|
+
let babelConfig = await readFile(join(existing.directory, "babel.config.js"), "utf-8");
|
|
122
|
+
|
|
123
|
+
expect(babelConfig).not.toContain("@babel/plugin-transform-typescript");
|
|
124
|
+
expect(babelConfig).toContain("module:decorator-transforms");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("builds (with example source written in)", async () => {
|
|
128
|
+
await writeLibrarySource(project, "javascript");
|
|
129
|
+
await installAndBuild(project);
|
|
130
|
+
|
|
131
|
+
let output = await read(project, "dist/index.js");
|
|
132
|
+
|
|
133
|
+
// Published libraries ship precompileTemplate (the consuming app does
|
|
134
|
+
// the final compile), never wire format
|
|
135
|
+
expect(output).toContain("precompileTemplate");
|
|
136
|
+
expect(output).not.toContain("createTemplateFactory");
|
|
137
|
+
|
|
138
|
+
expect(await listFiles(join(project.directory, "dist"))).toMatchInlineSnapshot(`
|
|
139
|
+
[
|
|
140
|
+
"index.js",
|
|
141
|
+
"index.js.map",
|
|
142
|
+
]
|
|
143
|
+
`);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("TypeScript", () => {
|
|
148
|
+
let project: Project;
|
|
149
|
+
|
|
150
|
+
beforeAll(async () => {
|
|
151
|
+
project = await generate({ type: "library", name: "my-lib", layers: ["typescript"] });
|
|
152
|
+
dirs.push(project.directory);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("generates the expected files (src is empty)", async () => {
|
|
156
|
+
expect(await listFiles(project.directory)).toMatchInlineSnapshot(`
|
|
157
|
+
[
|
|
158
|
+
".gitignore",
|
|
159
|
+
"README.md",
|
|
160
|
+
"package.json",
|
|
161
|
+
"src/index.ts",
|
|
162
|
+
"tsconfig.json",
|
|
163
|
+
"tsdown.config.js",
|
|
164
|
+
]
|
|
165
|
+
`);
|
|
166
|
+
|
|
167
|
+
expect(await read(project, "src/index.ts")).toBe("");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("type checks", async () => {
|
|
171
|
+
await writeLibrarySource(project, "typescript");
|
|
172
|
+
|
|
173
|
+
let install = await execa("pnpm install", { cwd: project.directory, shell: true });
|
|
174
|
+
expect(install.exitCode).toBe(0);
|
|
175
|
+
|
|
176
|
+
let types = await execa("pnpm lint:types", { cwd: project.directory, shell: true });
|
|
177
|
+
expect(types.exitCode).toBe(0);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("builds, including declarations", async () => {
|
|
181
|
+
await installAndBuild(project);
|
|
182
|
+
|
|
183
|
+
let output = await read(project, "dist/index.js");
|
|
184
|
+
|
|
185
|
+
expect(output).toContain("precompileTemplate");
|
|
186
|
+
expect(output).not.toContain("createTemplateFactory");
|
|
187
|
+
|
|
188
|
+
let declarations = await read(project, "dist/index.d.ts");
|
|
189
|
+
|
|
190
|
+
expect(declarations).toContain("declare class Greeting extends Component<GreetingSignature>");
|
|
191
|
+
expect(declarations).toContain("declare const Badge: TOC<BadgeSignature>");
|
|
192
|
+
expect(declarations).toContain("declare function add(a: number, b: number): number");
|
|
193
|
+
|
|
194
|
+
expect(await listFiles(join(project.directory, "dist"))).toMatchInlineSnapshot(`
|
|
195
|
+
[
|
|
196
|
+
"index.d.ts",
|
|
197
|
+
"index.d.ts.map",
|
|
198
|
+
"index.js",
|
|
199
|
+
"index.js.map",
|
|
200
|
+
]
|
|
201
|
+
`);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("errors when tsconfig lacks isolatedDeclarations", async () => {
|
|
205
|
+
let tsconfigPath = join(project.directory, "tsconfig.json");
|
|
206
|
+
let original = await readFile(tsconfigPath, "utf-8");
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
await writeFile(
|
|
210
|
+
tsconfigPath,
|
|
211
|
+
JSON.stringify({
|
|
212
|
+
extends: "@ember/library-tsconfig",
|
|
213
|
+
include: ["src"],
|
|
214
|
+
compilerOptions: { allowJs: true, rootDir: "./src" },
|
|
215
|
+
}),
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
let build = await execa("pnpm build", {
|
|
219
|
+
cwd: project.directory,
|
|
220
|
+
shell: true,
|
|
221
|
+
reject: false,
|
|
222
|
+
all: true,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
expect(build.exitCode).not.toBe(0);
|
|
226
|
+
expect(build.all).toContain('"compilerOptions.isolatedDeclarations": true');
|
|
227
|
+
} finally {
|
|
228
|
+
await writeFile(tsconfigPath, original);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
});
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { describe, it, expect, afterAll } from "vitest";
|
|
2
2
|
import { generate, layers, expectIsSetup } from "#test-helpers";
|
|
3
3
|
import { generateProject } from "ember.nvp";
|
|
4
|
-
import { writeFile } from "node:fs/promises";
|
|
4
|
+
import { rm, writeFile } from "node:fs/promises";
|
|
5
5
|
import { existsSync } from "node:fs";
|
|
6
6
|
import { join } from "node:path";
|
|
7
|
-
import { rimraf } from "rimraf";
|
|
8
7
|
|
|
9
8
|
/**
|
|
10
9
|
* The replace-or-update question only matters when a project already exists
|
|
@@ -21,7 +20,7 @@ describe("generating over an existing project", () => {
|
|
|
21
20
|
if (process.env.CI) return;
|
|
22
21
|
|
|
23
22
|
for (const dir of dirs) {
|
|
24
|
-
await
|
|
23
|
+
await rm(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
|
25
24
|
}
|
|
26
25
|
});
|
|
27
26
|
|