scripts/check-template-combinations.ts 10.7 K raw
1
#!/usr/bin/env bun
2
// Experimental script that lets you check if every possible template combination is created.
3
// Please use with CAUTION - A lot of parts have been written by AI
4
5
import fs from "fs-extra";
6
import path from "node:path";
7
8
// Define the possible boolean options from ProjectOptions type
9
const BOOLEAN_OPTIONS = [
10
	"tailwind",
11
	"shadcn",
12
	"rpc",
13
	"tanstackQuery",
14
	"reactRouter",
15
	"tanstackRouter",
16
] as const;
17
18
// Package dependency rules
19
const PACKAGE_DEPENDENCIES: Record<string, string[]> = {
20
	shadcn: ["tailwind"], // shadcn requires tailwind
21
	// Add more dependencies here as needed
22
	// example: somePackage: ["requiredPackage1", "requiredPackage2"]
23
};
24
25
// Mutually exclusive groups (only one option from each group can be selected)
26
const MUTUALLY_EXCLUSIVE_GROUPS: string[][] = [
27
	// Add mutually exclusive groups here as needed
28
	// example: ["option1", "option2", "option3"]
29
	["reactRouter", "tanstackRouter"],
30
];
31
32
// Check if a combination is valid based on dependencies and mutual exclusivity
33
function isValidCombination(combination: Record<string, boolean>): boolean {
34
	// Skip combinations with no packages selected
35
	const hasAnyPackage = Object.values(combination).some((value) => value);
36
	if (!hasAnyPackage) {
37
		return false;
38
	}
39
40
	// Skip combinations that only contain shadcn and/or tailwind (they're cloned from repo)
41
	const enabledPackages = Object.keys(combination).filter(
42
		(key) => combination[key],
43
	);
44
	const onlyShadcnTailwind = enabledPackages.every(
45
		(pkg) => pkg === "shadcn" || pkg === "tailwind",
46
	);
47
	if (onlyShadcnTailwind) {
48
		return false;
49
	}
50
51
	// Check package dependencies
52
	for (const [packageName, dependencies] of Object.entries(
53
		PACKAGE_DEPENDENCIES,
54
	)) {
55
		if (combination[packageName]) {
56
			// If this package is enabled, all its dependencies must also be enabled
57
			for (const dependency of dependencies) {
58
				if (!combination[dependency]) {
59
					return false;
60
				}
61
			}
62
		}
63
	}
64
65
	// Check mutual exclusivity
66
	for (const group of MUTUALLY_EXCLUSIVE_GROUPS) {
67
		const selectedInGroup = group.filter((option) => combination[option]);
68
		if (selectedInGroup.length > 1) {
69
			return false; // More than one option selected in mutually exclusive group
70
		}
71
	}
72
73
	return true;
74
}
75
76
// Generate all possible combinations of boolean options with filtering
77
function generateAllCombinations(
78
	options: readonly string[],
79
): Array<Record<string, boolean>> {
80
	const combinations: Array<Record<string, boolean>> = [];
81
	const totalCombinations = Math.pow(2, options.length);
82
83
	for (let i = 0; i < totalCombinations; i++) {
84
		const combination: Record<string, boolean> = {};
85
		for (let j = 0; j < options.length; j++) {
86
			combination[options[j]] = Boolean(i & (1 << j));
87
		}
88
89
		// Only include valid combinations
90
		if (isValidCombination(combination)) {
91
			combinations.push(combination);
92
		}
93
	}
94
95
	return combinations;
96
}
97
98
// Simulate nameGenerator function locally
99
const nameGenerator = (
100
	basename: string,
101
	possibleOptions: Record<string, boolean>,
102
) => {
103
	const dotIndex = basename.lastIndexOf(".");
104
	const filename = dotIndex === -1 ? basename : basename.substring(0, dotIndex);
105
	const extension = dotIndex === -1 ? "" : basename.substring(dotIndex + 1);
106
107
	const selectedOptions = Object.keys(possibleOptions)
108
		.filter((opt) => possibleOptions[opt])
109
		.map((opt) => opt.toLowerCase())
110
		.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
111
112
	if (selectedOptions.length > 0) {
113
		const suffix = ["-with", ...selectedOptions].join("-");
114
		return extension
115
			? `${filename}${suffix}.${extension}`
116
			: `${filename}${suffix}`;
117
	}
118
	return basename;
119
};
120
121
// Parse installer files to find nameGenerator calls and hardcoded template patterns
122
// Recursively find all .ts files in installers directory
123
async function findInstallerFiles(dir: string): Promise<string[]> {
124
	const files: string[] = [];
125
	const entries = await fs.readdir(dir, { withFileTypes: true });
126
127
	for (const entry of entries) {
128
		const fullPath = path.join(dir, entry.name);
129
		if (entry.isDirectory()) {
130
			files.push(...(await findInstallerFiles(fullPath)));
131
		} else if (entry.isFile() && entry.name.endsWith(".ts")) {
132
			files.push(fullPath);
133
		}
134
	}
135
136
	return files;
137
}
138
139
async function parseInstallerFiles(): Promise<
140
	Array<{
141
		file: string;
142
		basename: string;
143
		usedOptions: string[];
144
		templatePath: string;
145
		type: "nameGenerator" | "hardcoded";
146
	}>
147
> {
148
	const installerFiles = await findInstallerFiles("src/installers");
149
	const templateCalls: Array<{
150
		file: string;
151
		basename: string;
152
		usedOptions: string[];
153
		templatePath: string;
154
		type: "nameGenerator" | "hardcoded";
155
	}> = [];
156
157
	for (const file of installerFiles) {
158
		const content = await fs.readFile(file, "utf-8");
159
160
		// Find nameGenerator calls with regex
161
		const nameGeneratorRegex =
162
			/nameGenerator\s*\(\s*["']([^"']+)["']\s*,\s*\{([^}]+)\}/g;
163
		let match;
164
165
		while ((match = nameGeneratorRegex.exec(content)) !== null) {
166
			const basename = match[1];
167
			const optionsStr = match[2];
168
169
			// Extract the options used in this call
170
			const usedOptions = BOOLEAN_OPTIONS.filter((option) =>
171
				optionsStr.includes(option),
172
			);
173
174
			// Try to find the template path from the surrounding context
175
			const lines = content.split("\n");
176
			const matchLine =
177
				content.substring(0, match.index).split("\n").length - 1;
178
179
			let templatePath = "client/src"; // default based on common pattern
180
181
			// Look for path.join with EXTRAS_DIR in nearby lines (could be multi-line)
182
			for (
183
				let i = Math.max(0, matchLine - 10);
184
				i <= Math.min(lines.length - 1, matchLine + 10);
185
				i++
186
			) {
187
				const line = lines[i].trim();
188
				if (line.includes("path.join") && line.includes("EXTRAS_DIR")) {
189
					// Find the complete path.join statement (might span multiple lines)
190
					let pathJoinContent = "";
191
					let j = i;
192
					let parenCount = 0;
193
					let foundStart = false;
194
195
					while (j < lines.length) {
196
						const currentLine = lines[j].trim();
197
						pathJoinContent += currentLine + " ";
198
199
						if (currentLine.includes("path.join")) {
200
							foundStart = true;
201
						}
202
203
						if (foundStart) {
204
							parenCount += (currentLine.match(/\(/g) || []).length;
205
							parenCount -= (currentLine.match(/\)/g) || []).length;
206
207
							if (parenCount === 0) {
208
								break;
209
							}
210
						}
211
						j++;
212
					}
213
214
					// Extract path components from the complete path.join
215
					const pathMatch = pathJoinContent.match(/EXTRAS_DIR[^,]*,([^)]+)\)/);
216
					if (pathMatch) {
217
						templatePath = pathMatch[1]
218
							.split(",")
219
							.map((s) =>
220
								s
221
									.trim()
222
									.replace(/['"]/g, "")
223
									.replace(/nameGenerator[^,]*/, basename),
224
							)
225
							.filter((s) => s && s !== basename)
226
							.join("/");
227
					}
228
					break;
229
				}
230
			}
231
232
			templateCalls.push({
233
				file,
234
				basename,
235
				usedOptions,
236
				templatePath,
237
				type: "nameGenerator",
238
			});
239
		}
240
241
		// Find hardcoded template patterns like: `App-with${tailwind ? "-tailwind" : ""}${shadcn ? "-shadcn" : ""}${rpc ? "-rpc" : ""}.tsx`
242
		const hardcodedRegex = /`([^`]*)-with\${[^`]+\$\{[^`]+}\.[^`]+`/g;
243
		let hardcodedMatch;
244
245
		while ((hardcodedMatch = hardcodedRegex.exec(content)) !== null) {
246
			const templatePattern = hardcodedMatch[1];
247
248
			// Extract basename from pattern (everything before "-with")
249
			const basenameMatch = templatePattern.match(/^([^-]+)/);
250
			if (basenameMatch) {
251
				const basename = `${basenameMatch[1]}.tsx`; // Add .tsx extension
252
253
				// Extract options from the template pattern
254
				const usedOptions = BOOLEAN_OPTIONS.filter((option) =>
255
					hardcodedMatch[0].includes(option),
256
				);
257
258
				// Find template path similar to nameGenerator
259
				const lines = content.split("\n");
260
				const matchLine =
261
					content.substring(0, hardcodedMatch.index).split("\n").length - 1;
262
263
				let templatePath = "client/src";
264
265
				for (
266
					let i = Math.max(0, matchLine - 10);
267
					i <= Math.min(lines.length - 1, matchLine + 10);
268
					i++
269
				) {
270
					const line = lines[i].trim();
271
					if (line.includes("path.join") && line.includes("EXTRAS_DIR")) {
272
						let pathJoinContent = "";
273
						let j = i;
274
						let parenCount = 0;
275
						let foundStart = false;
276
277
						while (j < lines.length) {
278
							const currentLine = lines[j].trim();
279
							pathJoinContent += currentLine + " ";
280
281
							if (currentLine.includes("path.join")) {
282
								foundStart = true;
283
							}
284
285
							if (foundStart) {
286
								parenCount += (currentLine.match(/\(/g) || []).length;
287
								parenCount -= (currentLine.match(/\)/g) || []).length;
288
289
								if (parenCount === 0) {
290
									break;
291
								}
292
							}
293
							j++;
294
						}
295
296
						const pathMatch = pathJoinContent.match(
297
							/EXTRAS_DIR[^,]*,([^)]+)\)/,
298
						);
299
						if (pathMatch) {
300
							templatePath = pathMatch[1]
301
								.split(",")
302
								.map((s) =>
303
									s
304
										.trim()
305
										.replace(/['"]/g, "")
306
										.replace(/selectedTemplate[^,]*/, basename),
307
								)
308
								.filter(
309
									(s) => s && s !== basename && !s.includes("selectedTemplate"),
310
								)
311
								.join("/");
312
						}
313
						break;
314
					}
315
				}
316
317
				templateCalls.push({
318
					file,
319
					basename,
320
					usedOptions,
321
					templatePath,
322
					type: "hardcoded",
323
				});
324
			}
325
		}
326
	}
327
328
	return templateCalls;
329
}
330
331
// Simulate hardcoded template naming (like in RPC installer)
332
const hardcodedGenerator = (
333
	basename: string,
334
	possibleOptions: Record<string, boolean>,
335
) => {
336
	const dotIndex = basename.lastIndexOf(".");
337
	const filename = dotIndex === -1 ? basename : basename.substring(0, dotIndex);
338
	const extension = dotIndex === -1 ? "" : basename.substring(dotIndex + 1);
339
340
	let result = filename + "-with";
341
342
	// Hardcoded follows specific order: tailwind, shadcn, rpc, tanstackQuery, then routers
343
	if (possibleOptions.tailwind) result += "-tailwind";
344
	if (possibleOptions.shadcn) result += "-shadcn";
345
	if (possibleOptions.rpc) result += "-rpc";
346
	if (possibleOptions.tanstackQuery) result += "-tanstackquery";
347
	if (possibleOptions.reactRouter) result += "-reactrouter";
348
349
	return extension ? `${result}.${extension}` : result;
350
};
351
352
// Check if template files exist for all combinations
353
async function checkTemplateFiles() {
354
	const templateCalls = await parseInstallerFiles();
355
356
	if (templateCalls.length === 0) {
357
		return;
358
	}
359
360
	for (const call of templateCalls) {
361
		const allCombinations = generateAllCombinations(call.usedOptions);
362
		const templateDir = path.join(
363
			path.resolve("src/templates/extras"),
364
			call.templatePath.replace(/["']/g, ""),
365
			call.basename,
366
		);
367
368
		for (const combination of allCombinations) {
369
			const templateName =
370
				call.type === "nameGenerator"
371
					? nameGenerator(call.basename, combination)
372
					: hardcodedGenerator(call.basename, combination);
373
			const fullTemplatePath = path.join(templateDir, templateName);
374
375
			const exists = await fs.pathExists(fullTemplatePath);
376
			if (!exists) {
377
				console.log(`touch "${fullTemplatePath}"`);
378
			}
379
		}
380
	}
381
}
382
383
// Run the analysis
384
checkTemplateFiles().catch(console.error);