Repository: JoshuaKGoldberg/create-typescript-app (ts)

Issue:
🚀 Feature: Infer tsup.config.ts entries from disk
### Feature Request Checklist

- [x] I have pulled the latest `main` branch of the repository.
- [x] I have [searched for related issues](https://github.com/JoshuaKGoldberg/create-typescript-app/issues?q=is%3Aissue) and found none that matched my issue.

### Overview

Running `npx create-typescript-app` on a repo with existing `tsup.config.ts` customizations should read those in as Addons to `blockTSup`. That way `create-typescript-app.config.*` files won't need to explicitly describe them under `refinements`.

### Additional Info

https://github.com/JoshuaKGoldberg/package-json-validator/pull/178/files#diff-5b778358ef07c9914c10021cf98855c355bb26724b4a16956a714515b556f750R63-R67 is an example of `format` being specified.

🎁 

Pull request:
feat: add intake to blockTSup
## PR Checklist

- [x] Addresses an existing open issue: fixes #2114
- [x] That issue was marked as [`status: accepting prs`](https://github.com/JoshuaKGoldberg/create-typescript-app/issues?q=is%3Aopen+is%3Aissue+label%3A%22status%3A+accepting+prs%22)
- [x] Steps in [CONTRIBUTING.md](https://github.com/JoshuaKGoldberg/create-typescript-app/blob/main/.github/CONTRIBUTING.md) were taken

## Overview

Extracts `defineConfig` parsing into a shared `intakeFileDefineConfig`. Also fills in remaining tests for `intakeFile` while I'm here.

🎁 

Patch:
diff --git a/src/blocks/blockTSup.ts b/src/blocks/blockTSup.ts
index 409c89d..48ecdda 100644
--- a/src/blocks/blockTSup.ts
+++ b/src/blocks/blockTSup.ts
@@ -11,17 +11,34 @@ import { blockReleaseIt } from "./blockReleaseIt.js";
 import { blockRemoveDependencies } from "./blockRemoveDependencies.js";
 import { blockRemoveFiles } from "./blockRemoveFiles.js";
 import { blockRemoveWorkflows } from "./blockRemoveWorkflows.js";
+import { intakeFileDefineConfig } from "./intake/intakeFileDefineConfig.js";
 import { CommandPhase } from "./phases.js";
 
+const zEntry = z.array(z.string());
+const zProperties = z.record(z.unknown());
+
 export const blockTSup = base.createBlock({
 	about: {
 		name: "TSup",
 	},
 	addons: {
-		entry: z.array(z.string()).default([]),
-		properties: z.record(z.unknown()).default({}),
+		entry: zEntry.default([]),
+		properties: zProperties.default({}),
 		runInCI: z.array(z.string()).default([]),
 	},
+	intake({ files }) {
+		const rawData = intakeFileDefineConfig(files, ["tsup.config.ts"]);
+		if (!rawData) {
+			return undefined;
+		}
+
+		const { entry: rawEntry, ...rest } = rawData;
+
+		return {
+			entry: zEntry.safeParse(rawEntry).data,
+			properties: zProperties.safeParse(rest).data,
+		};
+	},
 	produce({ addons, options }) {
 		const { entry, properties, runInCI } = addons;
 
diff --git a/src/blocks/intake/intakeFileDefineConfig.ts b/src/blocks/intake/intakeFileDefineConfig.ts
new file mode 100644
index 0000000..9ef2080
--- /dev/null
+++ b/src/blocks/intake/intakeFileDefineConfig.ts
@@ -0,0 +1,36 @@
+import { IntakeDirectory } from "bingo-fs";
+import JSON5 from "json5";
+
+import { intakeFile } from "./intakeFile.js";
+
+export function intakeFileDefineConfig(
+	files: IntakeDirectory,
+	filePath: (string | string[])[],
+): Record<string, unknown> | undefined {
+	const file = intakeFile(files, filePath);
+	if (!file) {
+		return undefined;
+	}
+
+	const normalized = file[0].replaceAll(/[\n\r]/g, "");
+	const matched = /defineConfig\(\{(.+)\}\)\s*(?:;\s*)?$/u.exec(normalized);
+	if (!matched) {
+		return undefined;
+	}
+
+	const rawData = tryParseJSON5(`{${matched[1]}}`);
+	if (!rawData || typeof rawData !== "object") {
+		return undefined;
+	}
+
+	return rawData;
+}
+
+function tryParseJSON5(text: string) {
+	try {
+		// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
+		return JSON5.parse(text) as Record<string, unknown> | undefined;
+	} catch {
+		return undefined;
+	}
+}


Test patch:
diff --git a/src/blocks/blockTSup.test.ts b/src/blocks/blockTSup.test.ts
index 7376142..51cef27 100644
--- a/src/blocks/blockTSup.test.ts
+++ b/src/blocks/blockTSup.test.ts
@@ -1,5 +1,5 @@
-import { testBlock } from "bingo-stratum-testers";
-import { describe, expect, test, vi } from "vitest";
+import { testBlock, testIntake } from "bingo-stratum-testers";
+import { describe, expect, it, test, vi } from "vitest";
 
 import { blockTSup } from "./blockTSup.js";
 import { optionsBase } from "./options.fakes.js";
@@ -327,4 +327,99 @@ describe("blockTSup", () => {
 			}
 		`);
 	});
+
+	describe("intake", () => {
+		it("returns nothing when tsup.config.ts passes nothing to defineConfig", () => {
+			const actual = testIntake(blockTSup, {
+				files: {
+					"tsup.config.ts": [`defineConfig()`],
+				},
+				options: optionsBase,
+			});
+
+			expect(actual).toEqual(undefined);
+		});
+
+		it("returns nothing when tsup.config.ts passes invalid syntax to defineConfig", () => {
+			const actual = testIntake(blockTSup, {
+				files: {
+					"tsup.config.ts": [`defineConfig({ ! })`],
+				},
+				options: optionsBase,
+			});
+
+			expect(actual).toEqual(undefined);
+		});
+
+		it("returns entry when it exists alone in tsup.config.ts", () => {
+			const actual = testIntake(blockTSup, {
+				files: {
+					"tsup.config.ts": [
+						`import { defineConfig } from "tsup";
+	
+export default defineConfig({
+	entry: ["src/**/*.ts", "!src/**/*.test.*"],
+});
+	`,
+					],
+				},
+				options: optionsBase,
+			});
+
+			expect(actual).toEqual({
+				entry: ["src/**/*.ts", "!src/**/*.test.*"],
+				properties: {},
+			});
+		});
+
+		it("returns other properties when they exists without entry in tsup.config.ts", () => {
+			const actual = testIntake(blockTSup, {
+				files: {
+					"tsup.config.ts": [
+						`import { defineConfig } from "tsup";
+	
+export default defineConfig({
+	bundle: false,
+	format: "esm",
+});
+	`,
+					],
+				},
+				options: optionsBase,
+			});
+
+			expect(actual).toEqual({
+				properties: {
+					bundle: false,
+					format: "esm",
+				},
+			});
+		});
+
+		it("returns entry and other properties when they all exist in tsup.config.ts", () => {
+			const actual = testIntake(blockTSup, {
+				files: {
+					"tsup.config.ts": [
+						`import { defineConfig } from "tsup";
+	
+export default defineConfig({
+	bundle: false,
+	entry: ["src/**/*.ts", "!src/**/*.test.*"],
+	format: "esm",
+});
+	`,
+					],
+				},
+				options: optionsBase,
+			});
+
+			expect(actual).toEqual({
+				entry: ["src/**/*.ts", "!src/**/*.test.*"],
+				properties: {
+					bundle: false,
+					format: "esm",
+				},
+			});
+		});
+	});
 });
diff --git a/src/blocks/blockVitest.test.ts b/src/blocks/blockVitest.test.ts
index ce39bb1..691eaeb 100644
--- a/src/blocks/blockVitest.test.ts
+++ b/src/blocks/blockVitest.test.ts
@@ -1041,39 +1041,6 @@ describe("blockVitest", () => {
 	});
 
 	describe("intake", () => {
-		it("returns nothing when vitest.config.ts does not exist", () => {
-			const actual = testIntake(blockVitest, {
-				files: {
-					src: {},
-				},
-				options: optionsBase,
-			});
-
-			expect(actual).toEqual({});
-		});
-
-		it("returns nothing when vitest.config.ts does not contain the expected defineConfig", () => {
-			const actual = testIntake(blockVitest, {
-				files: {
-					"vitest.config.ts": [`invalid`],
-				},
-				options: optionsBase,
-			});
-
-			expect(actual).toEqual({});
-		});
-
-		it("returns nothing when vitest.config.ts passes a non-object to defineConfig", () => {
-			const actual = testIntake(blockVitest, {
-				files: {
-					"vitest.config.ts": [`defineConfig("invalid")`],
-				},
-				options: optionsBase,
-			});
-
-			expect(actual).toEqual({});
-		});
-
 		it("returns nothing when vitest.config.ts does not pass a test to defineConfig", () => {
 			const actual = testIntake(blockVitest, {
 				files: {
diff --git a/src/blocks/blockVitest.ts b/src/blocks/blockVitest.ts
index 9c437ab..04c1524 100644
--- a/src/blocks/blockVitest.ts
+++ b/src/blocks/blockVitest.ts
@@ -1,5 +1,4 @@
 import { IntakeDirectory } from "bingo-fs";
-import JSON5 from "json5";
 import { z } from "zod";
 
 import { base } from "../base.js";
@@ -18,16 +17,7 @@ import { blockRemoveFiles } from "./blockRemoveFiles.js";
 import { blockRemoveWorkflows } from "./blockRemoveWorkflows.js";
 import { blockTSup } from "./blockTSup.js";
 import { blockVSCode } from "./blockVSCode.js";
-import { intakeFile } from "./intake/intakeFile.js";
-
-function tryParseJSON5(text: string) {
-	try {
-		// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
-		return JSON5.parse(text) as Record<string, unknown> | undefined;
-	} catch {
-		return undefined;
-	}
-}
+import { intakeFileDefineConfig } from "./intake/intakeFileDefineConfig.js";
 
 const zCoverage = z.object({
 	exclude: z.array(z.string()).optional(),
@@ -47,19 +37,8 @@ const zTest = z
 	.partial();
 
 function intakeFromConfig(files: IntakeDirectory) {
-	const file = intakeFile(files, ["vitest.config.ts"]);
-	if (!file) {
-		return undefined;
-	}
-
-	const normalized = file[0].replaceAll(/[\n\r]/g, "");
-	const matched = /defineConfig\(\{(.+)\}\)\s*(?:;\s*)?$/u.exec(normalized);
-	if (!matched) {
-		return undefined;
-	}
-
-	const rawData = tryParseJSON5(`{${matched[1]}}`);
-	if (typeof rawData !== "object" || typeof rawData.test !== "object") {
+	const rawData = intakeFileDefineConfig(files, ["vitest.config.ts"]);
+	if (typeof rawData?.test !== "object") {
 		return undefined;
 	}
 
diff --git a/src/blocks/intake/intakeFile.test.ts b/src/blocks/intake/intakeFile.test.ts
index d7e0928..f6fa0ad 100644
--- a/src/blocks/intake/intakeFile.test.ts
+++ b/src/blocks/intake/intakeFile.test.ts
@@ -25,6 +25,28 @@ describe(intakeFile, () => {
 		expect(actual).toBeUndefined();
 	});
 
+	it("returns undefined when filePath points to a root-level directory", () => {
+		const actual = intakeFile(
+			{
+				"file.txt": {},
+			},
+			["file.txt"],
+		);
+
+		expect(actual).toBeUndefined();
+	});
+
+	it("returns undefined when filePath points to a root-level undefined", () => {
+		const actual = intakeFile(
+			{
+				"file.txt": undefined,
+			},
+			["file.txt"],
+		);
+
+		expect(actual).toBeUndefined();
+	});
+
 	it("returns the file when filePath points to a root-level file", () => {
 		const value = "abc123";
 
@@ -38,6 +60,17 @@ describe(intakeFile, () => {
 		expect(actual).toEqual([value]);
 	});
 
+	it("returns undefined when filePath is within a file", () => {
+		const actual = intakeFile(
+			{
+				src: ["abc123"],
+			},
+			["src", "file.txt"],
+		);
+
+		expect(actual).toBeUndefined();
+	});
+
 	it("returns the file when filePath points to a file in a directory", () => {
 		const value = "abc123";
 
@@ -52,4 +85,90 @@ describe(intakeFile, () => {
 
 		expect(actual).toEqual([value]);
 	});
+
+	it("returns undefined when filePath points to a directory in a directory", () => {
+		const actual = intakeFile(
+			{
+				src: {
+					"file.txt": {},
+				},
+			},
+			["src", "file.txt"],
+		);
+
+		expect(actual).toBeUndefined();
+	});
+
+	it("returns the file when filePath does not point to a file in the root", () => {
+		const value = "abc123";
+
+		const actual = intakeFile(
+			{
+				"file.txt": [value],
+			},
+			["other.txt"],
+		);
+
+		expect(actual).toBeUndefined();
+	});
+
+	it("returns the file when filePath does not point to a file in a directory", () => {
+		const value = "abc123";
+
+		const actual = intakeFile(
+			{
+				src: {
+					"file.txt": [value],
+				},
+			},
+			["src", "other.txt"],
+		);
+
+		expect(actual).toBeUndefined();
+	});
+
+	it("returns the file when filePath is an array whose first element points to a file in a directory", () => {
+		const value = "abc123";
+
+		const actual = intakeFile(
+			{
+				src: {
+					"file.txt": [value],
+				},
+			},
+			["src", ["file.txt", "other.txt"]],
+		);
+
+		expect(actual).toEqual([value]);
+	});
+
+	it("returns the file when filePath is an array whose second element points to a file in a directory", () => {
+		const value = "abc123";
+
+		const actual = intakeFile(
+			{
+				src: {
+					"file.txt": [value],
+				},
+			},
+			["src", ["other.txt", "file.txt"]],
+		);
+
+		expect(actual).toEqual([value]);
+	});
+
+	it("returns nothing when filePath is an array and no element points to a file in a directory", () => {
+		const value = "abc123";
+
+		const actual = intakeFile(
+			{
+				src: {
+					"file.txt": [value],
+				},
+			},
+			["src", ["other-a.txt", "other-b.txt"]],
+		);
+
+		expect(actual).toBeUndefined();
+	});
 });
diff --git a/src/blocks/intake/intakeFileDefineConfig.test.ts b/src/blocks/intake/intakeFileDefineConfig.test.ts
new file mode 100644
index 0000000..ceb6df5
--- /dev/null
+++ b/src/blocks/intake/intakeFileDefineConfig.test.ts
@@ -0,0 +1,77 @@
+import { describe, expect, it } from "vitest";
+
+import { intakeFileDefineConfig } from "./intakeFileDefineConfig.js";
+
+describe("intake", () => {
+	it("returns nothing when the filePath file does not exist", () => {
+		const actual = intakeFileDefineConfig({}, ["file.config.ts"]);
+
+		expect(actual).toBeUndefined();
+	});
+
+	it("returns nothing when the filePath file does not contain the expected defineConfig", () => {
+		const actual = intakeFileDefineConfig(
+			{
+				"file.config.ts": [`invalid`],
+			},
+			["file.config.ts"],
+		);
+
+		expect(actual).toBeUndefined();
+	});
+
+	it("returns nothing when the filePath file passes nothing to defineConfig", () => {
+		const actual = intakeFileDefineConfig(
+			{
+				"file.config.ts": [`defineConfig()`],
+			},
+			["file.config.ts"],
+		);
+
+		expect(actual).toBeUndefined();
+	});
+
+	it("returns nothing when the filePath file passes invalid data to defineConfig", () => {
+		const actual = intakeFileDefineConfig(
+			{
+				"file.config.ts": [`defineConfig(invalid)`],
+			},
+			["file.config.ts"],
+		);
+
+		expect(actual).toBeUndefined();
+	});
+
+	it("returns nothing when the filePath file passes a non-object to defineConfig", () => {
+		const actual = intakeFileDefineConfig(
+			{
+				"file.config.ts": [`defineConfig("invalid")`],
+			},
+			["file.config.ts"],
+		);
+
+		expect(actual).toBeUndefined();
+	});
+
+	it("returns values when they exist in the filePath file", () => {
+		const actual = intakeFileDefineConfig(
+			{
+				"file.config.ts": [
+					`import { defineConfig } from "...";
+
+export default defineConfig({
+	abc: 123,
+	def: 456,
+});
+`,
+				],
+			},
+			["file.config.ts"],
+		);
+
+		expect(actual).toEqual({
+			abc: 123,
+			def: 456,
+		});
+	});
+});