feat(cli): add ultracite, oxlint, fumadocs addons (#427)

This commit is contained in:
Aman Varshney
2025-07-29 00:13:51 +05:30
committed by GitHub
parent 82a4f42eca
commit 216c242f7d
66 changed files with 794 additions and 251 deletions

View File

@@ -0,0 +1,9 @@
---
"create-better-t-stack": minor
---
Added addons: fumadocs, ultracite, oxlint
Added bunfig.toml with isolated linker
Grouped addon prompts

View File

@@ -1,6 +1,6 @@
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import type { Frontend, ProjectConfig } from "./types"; import type { Addons, Frontend, ProjectConfig } from "./types";
import { getUserPkgManager } from "./utils/get-package-manager"; import { getUserPkgManager } from "./utils/get-package-manager";
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
@@ -55,10 +55,12 @@ export const dependencyVersionMap = {
"@tauri-apps/cli": "^2.4.0", "@tauri-apps/cli": "^2.4.0",
"@biomejs/biome": "^2.0.0", "@biomejs/biome": "^2.1.2",
oxlint: "^1.8.0",
ultracite: "5.1.1",
husky: "^9.1.7", husky: "^9.1.7",
"lint-staged": "^15.5.0", "lint-staged": "^16.1.2",
tsx: "^4.19.2", tsx: "^4.19.2",
"@types/node": "^22.13.11", "@types/node": "^22.13.11",
@@ -119,13 +121,16 @@ export const dependencyVersionMap = {
export type AvailableDependencies = keyof typeof dependencyVersionMap; export type AvailableDependencies = keyof typeof dependencyVersionMap;
export const ADDON_COMPATIBILITY = { export const ADDON_COMPATIBILITY: Record<Addons, readonly Frontend[]> = {
pwa: ["tanstack-router", "react-router", "solid", "next"], pwa: ["tanstack-router", "react-router", "solid", "next"],
tauri: ["tanstack-router", "react-router", "nuxt", "svelte", "solid"], tauri: ["tanstack-router", "react-router", "nuxt", "svelte", "solid"],
biome: [], biome: [],
husky: [], husky: [],
turborepo: [], turborepo: [],
starlight: [], starlight: [],
ultracite: [],
oxlint: [],
fumadocs: [],
none: [], none: [],
} as const; } as const;

View File

@@ -5,7 +5,7 @@ import {
type EnvVariable, type EnvVariable,
} from "../project-generation/env-setup"; } from "../project-generation/env-setup";
export async function setupCloudflareD1(config: ProjectConfig): Promise<void> { export async function setupCloudflareD1(config: ProjectConfig) {
const { projectDir } = config; const { projectDir } = config;
const envPath = path.join(projectDir, "apps/server", ".env"); const envPath = path.join(projectDir, "apps/server", ".env");

View File

@@ -5,7 +5,7 @@ import {
type EnvVariable, type EnvVariable,
} from "../project-generation/env-setup"; } from "../project-generation/env-setup";
export async function setupDockerCompose(config: ProjectConfig): Promise<void> { export async function setupDockerCompose(config: ProjectConfig) {
const { database, projectDir, projectName } = config; const { database, projectDir, projectName } = config;
if (database === "none" || database === "sqlite") { if (database === "none" || database === "sqlite") {

View File

@@ -156,7 +156,7 @@ function displayManualSetupInstructions() {
DATABASE_URL="your_connection_string"`); DATABASE_URL="your_connection_string"`);
} }
export async function setupNeonPostgres(config: ProjectConfig): Promise<void> { export async function setupNeonPostgres(config: ProjectConfig) {
const { packageManager, projectDir } = config; const { packageManager, projectDir } = config;
try { try {

View File

@@ -206,7 +206,7 @@ DATABASE_URL=your_database_url
DATABASE_AUTH_TOKEN=your_auth_token`); DATABASE_AUTH_TOKEN=your_auth_token`);
} }
export async function setupTurso(config: ProjectConfig): Promise<void> { export async function setupTurso(config: ProjectConfig) {
const { orm, projectDir } = config; const { orm, projectDir } = config;
const _isDrizzle = orm === "drizzle"; const _isDrizzle = orm === "drizzle";
const setupSpinner = spinner(); const setupSpinner = spinner();

View File

@@ -19,7 +19,7 @@ function exitWithError(message: string): never {
export async function addAddonsToProject( export async function addAddonsToProject(
input: AddInput & { addons: Addons[]; suppressInstallMessage?: boolean }, input: AddInput & { addons: Addons[]; suppressInstallMessage?: boolean },
): Promise<void> { ) {
try { try {
const projectDir = input.projectDir || process.cwd(); const projectDir = input.projectDir || process.cwd();

View File

@@ -18,7 +18,7 @@ function exitWithError(message: string): never {
export async function addDeploymentToProject( export async function addDeploymentToProject(
input: AddInput & { webDeploy: WebDeploy; suppressInstallMessage?: boolean }, input: AddInput & { webDeploy: WebDeploy; suppressInstallMessage?: boolean },
): Promise<void> { ) {
try { try {
const projectDir = input.projectDir || process.cwd(); const projectDir = input.projectDir || process.cwd();

View File

@@ -136,7 +136,7 @@ export async function createProjectHandler(
} }
} }
export async function addAddonsHandler(input: AddInput): Promise<void> { export async function addAddonsHandler(input: AddInput) {
try { try {
const projectDir = input.projectDir || process.cwd(); const projectDir = input.projectDir || process.cwd();
const detectedConfig = await detectProjectConfig(projectDir); const detectedConfig = await detectProjectConfig(projectDir);

View File

@@ -12,7 +12,7 @@ export interface EnvVariable {
export async function addEnvVariablesToFile( export async function addEnvVariablesToFile(
filePath: string, filePath: string,
variables: EnvVariable[], variables: EnvVariable[],
): Promise<void> { ) {
await fs.ensureDir(path.dirname(filePath)); await fs.ensureDir(path.dirname(filePath));
let envContent = ""; let envContent = "";
@@ -84,9 +84,7 @@ export async function addEnvVariablesToFile(
} }
} }
export async function setupEnvironmentVariables( export async function setupEnvironmentVariables(config: ProjectConfig) {
config: ProjectConfig,
): Promise<void> {
const { backend, frontend, database, auth, examples, dbSetup, projectDir } = const { backend, frontend, database, auth, examples, dbSetup, projectDir } =
config; config;

View File

@@ -2,10 +2,7 @@ import { log } from "@clack/prompts";
import { $ } from "execa"; import { $ } from "execa";
import pc from "picocolors"; import pc from "picocolors";
export async function initializeGit( export async function initializeGit(projectDir: string, useGit: boolean) {
projectDir: string,
useGit: boolean,
): Promise<void> {
if (!useGit) return; if (!useGit) return;
const gitVersionResult = await $({ const gitVersionResult = await $({

View File

@@ -142,6 +142,10 @@ export async function displayPostInstallInstructions(
output += `${pc.cyan("•")} Docs: http://localhost:4321\n`; output += `${pc.cyan("•")} Docs: http://localhost:4321\n`;
} }
if (addons?.includes("fumadocs")) {
output += `${pc.cyan("•")} Fumadocs: http://localhost:4000\n`;
}
if (nativeInstructions) output += `\n${nativeInstructions.trim()}\n`; if (nativeInstructions) output += `\n${nativeInstructions.trim()}\n`;
if (databaseInstructions) output += `\n${databaseInstructions.trim()}\n`; if (databaseInstructions) output += `\n${databaseInstructions.trim()}\n`;
if (tauriInstructions) output += `\n${tauriInstructions.trim()}\n`; if (tauriInstructions) output += `\n${tauriInstructions.trim()}\n`;

View File

@@ -7,7 +7,7 @@ import type { ProjectConfig } from "../../types";
export async function updatePackageConfigurations( export async function updatePackageConfigurations(
projectDir: string, projectDir: string,
options: ProjectConfig, options: ProjectConfig,
): Promise<void> { ) {
await updateRootPackageJson(projectDir, options); await updateRootPackageJson(projectDir, options);
if (options.backend !== "convex") { if (options.backend !== "convex") {
await updateServerPackageJson(projectDir, options); await updateServerPackageJson(projectDir, options);
@@ -19,7 +19,7 @@ export async function updatePackageConfigurations(
async function updateRootPackageJson( async function updateRootPackageJson(
projectDir: string, projectDir: string,
options: ProjectConfig, options: ProjectConfig,
): Promise<void> { ) {
const rootPackageJsonPath = path.join(projectDir, "package.json"); const rootPackageJsonPath = path.join(projectDir, "package.json");
if (!(await fs.pathExists(rootPackageJsonPath))) return; if (!(await fs.pathExists(rootPackageJsonPath))) return;
@@ -185,18 +185,6 @@ async function updateRootPackageJson(
} }
} }
if (options.addons.includes("biome")) {
scripts.check = "biome check --write .";
}
if (options.addons.includes("husky")) {
scripts.prepare = "husky";
packageJson["lint-staged"] = {
"*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc}": [
"biome check --write .",
],
};
}
try { try {
const { stdout } = await execa(options.packageManager, ["-v"], { const { stdout } = await execa(options.packageManager, ["-v"], {
cwd: projectDir, cwd: projectDir,
@@ -235,7 +223,7 @@ async function updateRootPackageJson(
async function updateServerPackageJson( async function updateServerPackageJson(
projectDir: string, projectDir: string,
options: ProjectConfig, options: ProjectConfig,
): Promise<void> { ) {
const serverPackageJsonPath = path.join( const serverPackageJsonPath = path.join(
projectDir, projectDir,
"apps/server/package.json", "apps/server/package.json",
@@ -287,7 +275,7 @@ async function updateServerPackageJson(
async function updateConvexPackageJson( async function updateConvexPackageJson(
projectDir: string, projectDir: string,
options: ProjectConfig, options: ProjectConfig,
): Promise<void> { ) {
const convexPackageJsonPath = path.join( const convexPackageJsonPath = path.join(
projectDir, projectDir,
"packages/backend/package.json", "packages/backend/package.json",

View File

@@ -11,7 +11,7 @@ async function processAndCopyFiles(
destDir: string, destDir: string,
context: ProjectConfig, context: ProjectConfig,
overwrite = true, overwrite = true,
): Promise<void> { ) {
const sourceFiles = await globby(sourcePattern, { const sourceFiles = await globby(sourcePattern, {
cwd: baseSourceDir, cwd: baseSourceDir,
dot: true, dot: true,
@@ -54,7 +54,7 @@ async function processAndCopyFiles(
export async function copyBaseTemplate( export async function copyBaseTemplate(
projectDir: string, projectDir: string,
context: ProjectConfig, context: ProjectConfig,
): Promise<void> { ) {
const templateDir = path.join(PKG_ROOT, "templates/base"); const templateDir = path.join(PKG_ROOT, "templates/base");
await processAndCopyFiles(["**/*"], templateDir, projectDir, context); await processAndCopyFiles(["**/*"], templateDir, projectDir, context);
} }
@@ -62,7 +62,7 @@ export async function copyBaseTemplate(
export async function setupFrontendTemplates( export async function setupFrontendTemplates(
projectDir: string, projectDir: string,
context: ProjectConfig, context: ProjectConfig,
): Promise<void> { ) {
const hasReactWeb = context.frontend.some((f) => const hasReactWeb = context.frontend.some((f) =>
["tanstack-router", "react-router", "tanstack-start", "next"].includes(f), ["tanstack-router", "react-router", "tanstack-start", "next"].includes(f),
); );
@@ -241,7 +241,7 @@ export async function setupFrontendTemplates(
export async function setupBackendFramework( export async function setupBackendFramework(
projectDir: string, projectDir: string,
context: ProjectConfig, context: ProjectConfig,
): Promise<void> { ) {
if (context.backend === "none") { if (context.backend === "none") {
return; return;
} }
@@ -332,7 +332,7 @@ export async function setupBackendFramework(
export async function setupDbOrmTemplates( export async function setupDbOrmTemplates(
projectDir: string, projectDir: string,
context: ProjectConfig, context: ProjectConfig,
): Promise<void> { ) {
if ( if (
context.backend === "convex" || context.backend === "convex" ||
context.orm === "none" || context.orm === "none" ||
@@ -357,7 +357,7 @@ export async function setupDbOrmTemplates(
export async function setupAuthTemplate( export async function setupAuthTemplate(
projectDir: string, projectDir: string,
context: ProjectConfig, context: ProjectConfig,
): Promise<void> { ) {
if (context.backend === "convex" || !context.auth) return; if (context.backend === "convex" || !context.auth) return;
const serverAppDir = path.join(projectDir, "apps/server"); const serverAppDir = path.join(projectDir, "apps/server");
@@ -529,7 +529,7 @@ export async function setupAuthTemplate(
export async function setupAddonsTemplate( export async function setupAddonsTemplate(
projectDir: string, projectDir: string,
context: ProjectConfig, context: ProjectConfig,
): Promise<void> { ) {
if (!context.addons || context.addons.length === 0) return; if (!context.addons || context.addons.length === 0) return;
for (const addon of context.addons) { for (const addon of context.addons) {
@@ -567,7 +567,7 @@ export async function setupAddonsTemplate(
export async function setupExamplesTemplate( export async function setupExamplesTemplate(
projectDir: string, projectDir: string,
context: ProjectConfig, context: ProjectConfig,
): Promise<void> { ) {
if ( if (
!context.examples || !context.examples ||
context.examples.length === 0 || context.examples.length === 0 ||
@@ -773,10 +773,7 @@ export async function setupExamplesTemplate(
} }
} }
export async function handleExtras( export async function handleExtras(projectDir: string, context: ProjectConfig) {
projectDir: string,
context: ProjectConfig,
): Promise<void> {
const extrasDir = path.join(PKG_ROOT, "templates/extras"); const extrasDir = path.join(PKG_ROOT, "templates/extras");
const hasNativeWind = context.frontend.includes("native-nativewind"); const hasNativeWind = context.frontend.includes("native-nativewind");
const hasUnistyles = context.frontend.includes("native-unistyles"); const hasUnistyles = context.frontend.includes("native-unistyles");
@@ -790,6 +787,14 @@ export async function handleExtras(
} }
} }
if (context.packageManager === "bun") {
const bunfigSrc = path.join(extrasDir, "bunfig.toml");
const bunfigDest = path.join(projectDir, "bunfig.toml");
if (await fs.pathExists(bunfigSrc)) {
await fs.copy(bunfigSrc, bunfigDest);
}
}
if ( if (
context.packageManager === "pnpm" && context.packageManager === "pnpm" &&
(hasNative || context.frontend.includes("nuxt")) (hasNative || context.frontend.includes("nuxt"))
@@ -818,7 +823,7 @@ export async function handleExtras(
export async function setupDockerComposeTemplates( export async function setupDockerComposeTemplates(
projectDir: string, projectDir: string,
context: ProjectConfig, context: ProjectConfig,
): Promise<void> { ) {
if (context.dbSetup !== "docker" || context.database === "none") { if (context.dbSetup !== "docker" || context.database === "none") {
return; return;
} }
@@ -838,7 +843,7 @@ export async function setupDockerComposeTemplates(
export async function setupDeploymentTemplates( export async function setupDeploymentTemplates(
projectDir: string, projectDir: string,
context: ProjectConfig, context: ProjectConfig,
): Promise<void> { ) {
if (context.webDeploy === "none") { if (context.webDeploy === "none") {
return; return;
} }

View File

@@ -1,15 +1,19 @@
import path from "node:path"; import path from "node:path";
import { log } from "@clack/prompts"; import { log } from "@clack/prompts";
import { execa } from "execa";
import fs from "fs-extra"; import fs from "fs-extra";
import pc from "picocolors"; import pc from "picocolors";
import type { Frontend, ProjectConfig } from "../../types"; import type { Frontend, PackageManager, ProjectConfig } from "../../types";
import { addPackageDependency } from "../../utils/add-package-deps"; import { addPackageDependency } from "../../utils/add-package-deps";
import { getPackageExecutionCommand } from "../../utils/package-runner";
import { setupFumadocs } from "./fumadocs-setup";
import { setupStarlight } from "./starlight-setup"; import { setupStarlight } from "./starlight-setup";
import { setupTauri } from "./tauri-setup"; import { setupTauri } from "./tauri-setup";
import { setupUltracite } from "./ultracite-setup";
import { addPwaToViteConfig } from "./vite-pwa-setup"; import { addPwaToViteConfig } from "./vite-pwa-setup";
export async function setupAddons(config: ProjectConfig, isAddCommand = false) { export async function setupAddons(config: ProjectConfig, isAddCommand = false) {
const { addons, frontend, projectDir } = config; const { addons, frontend, projectDir, packageManager } = config;
const hasReactWebFrontend = const hasReactWebFrontend =
frontend.includes("react-router") || frontend.includes("react-router") ||
frontend.includes("tanstack-router") || frontend.includes("tanstack-router") ||
@@ -53,15 +57,37 @@ ${pc.cyan("Docs:")} ${pc.underline("https://turborepo.com/docs")}
) { ) {
await setupTauri(config); await setupTauri(config);
} }
if (addons.includes("biome")) { const hasUltracite = addons.includes("ultracite");
await setupBiome(projectDir); const hasBiome = addons.includes("biome");
const hasHusky = addons.includes("husky");
const hasOxlint = addons.includes("oxlint");
if (hasUltracite) {
await setupUltracite(config, hasHusky);
} else {
if (hasBiome) {
await setupBiome(projectDir);
}
if (hasHusky) {
let linter: "biome" | "oxlint" | undefined;
if (hasOxlint) {
linter = "oxlint";
} else if (hasBiome) {
linter = "biome";
}
await setupHusky(projectDir, linter);
}
} }
if (addons.includes("husky")) {
await setupHusky(projectDir); if (addons.includes("oxlint")) {
await setupOxlint(projectDir, packageManager);
} }
if (addons.includes("starlight")) { if (addons.includes("starlight")) {
await setupStarlight(config); await setupStarlight(config);
} }
if (addons.includes("fumadocs")) {
await setupFumadocs(config);
}
} }
function getWebAppDir(projectDir: string, frontends: Frontend[]): string { function getWebAppDir(projectDir: string, frontends: Frontend[]): string {
@@ -77,7 +103,7 @@ function getWebAppDir(projectDir: string, frontends: Frontend[]): string {
return path.join(projectDir, "apps/web"); return path.join(projectDir, "apps/web");
} }
async function setupBiome(projectDir: string) { export async function setupBiome(projectDir: string) {
await addPackageDependency({ await addPackageDependency({
devDependencies: ["@biomejs/biome"], devDependencies: ["@biomejs/biome"],
projectDir, projectDir,
@@ -96,7 +122,10 @@ async function setupBiome(projectDir: string) {
} }
} }
async function setupHusky(projectDir: string) { export async function setupHusky(
projectDir: string,
linter?: "biome" | "oxlint",
) {
await addPackageDependency({ await addPackageDependency({
devDependencies: ["husky", "lint-staged"], devDependencies: ["husky", "lint-staged"],
projectDir, projectDir,
@@ -111,11 +140,21 @@ async function setupHusky(projectDir: string) {
prepare: "husky", prepare: "husky",
}; };
packageJson["lint-staged"] = { if (linter === "oxlint") {
"*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc}": [ packageJson["lint-staged"] = {
"biome check --write .", "**/*.{js,mjs,cjs,jsx,ts,mts,cts,tsx,vue,astro,svelte}": "oxlint",
], };
}; } else if (linter === "biome") {
packageJson["lint-staged"] = {
"*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc}": [
"biome check --write .",
],
};
} else {
packageJson["lint-staged"] = {
"**/*.{js,mjs,cjs,jsx,ts,mts,cts,tsx,vue,astro,svelte}": "",
};
}
await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 }); await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 });
} }
@@ -157,3 +196,33 @@ async function setupPwa(projectDir: string, frontends: Frontend[]) {
await addPwaToViteConfig(viteConfigTs, path.basename(projectDir)); await addPwaToViteConfig(viteConfigTs, path.basename(projectDir));
} }
} }
async function setupOxlint(projectDir: string, packageManager: PackageManager) {
await addPackageDependency({
devDependencies: ["oxlint"],
projectDir,
});
const packageJsonPath = path.join(projectDir, "package.json");
if (await fs.pathExists(packageJsonPath)) {
const packageJson = await fs.readJson(packageJsonPath);
packageJson.scripts = {
...packageJson.scripts,
check: "oxlint",
};
await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 });
}
const oxlintInitCommand = getPackageExecutionCommand(
packageManager,
"oxlint@latest --init",
);
await execa(oxlintInitCommand, {
cwd: projectDir,
env: { CI: "true" },
shell: true,
});
}

View File

@@ -4,7 +4,7 @@ import type { AvailableDependencies } from "../../constants";
import type { Frontend, ProjectConfig } from "../../types"; import type { Frontend, ProjectConfig } from "../../types";
import { addPackageDependency } from "../../utils/add-package-deps"; import { addPackageDependency } from "../../utils/add-package-deps";
export async function setupApi(config: ProjectConfig): Promise<void> { export async function setupApi(config: ProjectConfig) {
const { api, projectName, frontend, backend, packageManager, projectDir } = const { api, projectName, frontend, backend, packageManager, projectDir } =
config; config;
const isConvex = backend === "convex"; const isConvex = backend === "convex";

View File

@@ -5,7 +5,7 @@ import pc from "picocolors";
import type { ProjectConfig } from "../../types"; import type { ProjectConfig } from "../../types";
import { addPackageDependency } from "../../utils/add-package-deps"; import { addPackageDependency } from "../../utils/add-package-deps";
export async function setupAuth(config: ProjectConfig): Promise<void> { export async function setupAuth(config: ProjectConfig) {
const { auth, frontend, backend, projectDir } = config; const { auth, frontend, backend, projectDir } = config;
if (backend === "convex" || !auth) { if (backend === "convex" || !auth) {
return; return;

View File

@@ -3,9 +3,7 @@ import type { AvailableDependencies } from "../../constants";
import type { ProjectConfig } from "../../types"; import type { ProjectConfig } from "../../types";
import { addPackageDependency } from "../../utils/add-package-deps"; import { addPackageDependency } from "../../utils/add-package-deps";
export async function setupBackendDependencies( export async function setupBackendDependencies(config: ProjectConfig) {
config: ProjectConfig,
): Promise<void> {
const { backend, runtime, api, projectDir } = config; const { backend, runtime, api, projectDir } = config;
if (backend === "convex") { if (backend === "convex") {

View File

@@ -13,7 +13,7 @@ import { setupPrismaPostgres } from "../database-providers/prisma-postgres-setup
import { setupSupabase } from "../database-providers/supabase-setup"; import { setupSupabase } from "../database-providers/supabase-setup";
import { setupTurso } from "../database-providers/turso-setup"; import { setupTurso } from "../database-providers/turso-setup";
export async function setupDatabase(config: ProjectConfig): Promise<void> { export async function setupDatabase(config: ProjectConfig) {
const { database, orm, dbSetup, backend, projectDir } = config; const { database, orm, dbSetup, backend, projectDir } = config;
if (backend === "convex" || database === "none") { if (backend === "convex" || database === "none") {

View File

@@ -4,7 +4,7 @@ import type { AvailableDependencies } from "../../constants";
import type { ProjectConfig } from "../../types"; import type { ProjectConfig } from "../../types";
import { addPackageDependency } from "../../utils/add-package-deps"; import { addPackageDependency } from "../../utils/add-package-deps";
export async function setupExamples(config: ProjectConfig): Promise<void> { export async function setupExamples(config: ProjectConfig) {
const { examples, frontend, backend, projectDir } = config; const { examples, frontend, backend, projectDir } = config;
if ( if (

View File

@@ -0,0 +1,96 @@
import path from "node:path";
import { cancel, isCancel, log, select } from "@clack/prompts";
import consola from "consola";
import { execa } from "execa";
import fs from "fs-extra";
import pc from "picocolors";
import type { ProjectConfig } from "../../types";
import { getPackageExecutionCommand } from "../../utils/package-runner";
type FumadocsTemplate =
| "next-mdx"
| "next-content-collections"
| "react-router-mdx-remote"
| "tanstack-start-mdx-remote";
const TEMPLATES = {
"next-mdx": {
label: "Next.js: Fumadocs MDX",
hint: "Recommended template with MDX support",
value: "+next+fuma-docs-mdx",
},
"next-content-collections": {
label: "Next.js: Content Collections",
hint: "Template using Next.js content collections",
value: "+next+content-collections",
},
"react-router-mdx-remote": {
label: "React Router: MDX Remote",
hint: "Template for React Router with MDX remote",
value: "react-router",
},
"tanstack-start-mdx-remote": {
label: "Tanstack Start: MDX Remote",
hint: "Template for Tanstack Start with MDX remote",
value: "tanstack-start",
},
} as const;
export async function setupFumadocs(config: ProjectConfig) {
const { packageManager, projectDir } = config;
try {
log.info("Setting up Fumadocs...");
const template = await select<FumadocsTemplate>({
message: "Choose a template",
options: Object.entries(TEMPLATES).map(([key, template]) => ({
value: key as FumadocsTemplate,
label: template.label,
hint: template.hint,
})),
initialValue: "next-mdx",
});
if (isCancel(template)) {
cancel(pc.red("Operation cancelled"));
process.exit(0);
}
const templateArg = TEMPLATES[template].value;
const commandWithArgs = `create-fumadocs-app@latest fumadocs --template ${templateArg} --src --no-install --pm ${packageManager} --no-eslint`;
const fumadocsInitCommand = getPackageExecutionCommand(
packageManager,
commandWithArgs,
);
await execa(fumadocsInitCommand, {
cwd: path.join(projectDir, "apps"),
env: { CI: "true" },
shell: true,
});
const fumadocsDir = path.join(projectDir, "apps", "fumadocs");
const packageJsonPath = path.join(fumadocsDir, "package.json");
if (await fs.pathExists(packageJsonPath)) {
const packageJson = await fs.readJson(packageJsonPath);
packageJson.name = "fumadocs";
if (packageJson.scripts?.dev) {
packageJson.scripts.dev = `${packageJson.scripts.dev} --port=4000`;
}
await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 });
}
log.success("Fumadocs setup successfully!");
} catch (error) {
log.error(pc.red("Failed to set up Fumadocs"));
if (error instanceof Error) {
consola.error(pc.red(error.message));
}
}
}

View File

@@ -6,7 +6,7 @@ import pc from "picocolors";
import type { Backend, ProjectConfig } from "../../types"; import type { Backend, ProjectConfig } from "../../types";
import { addPackageDependency } from "../../utils/add-package-deps"; import { addPackageDependency } from "../../utils/add-package-deps";
export async function setupRuntime(config: ProjectConfig): Promise<void> { export async function setupRuntime(config: ProjectConfig) {
const { runtime, backend, projectDir } = config; const { runtime, backend, projectDir } = config;
if (backend === "convex" || backend === "next" || runtime === "none") { if (backend === "convex" || backend === "next" || runtime === "none") {
@@ -28,9 +28,7 @@ export async function setupRuntime(config: ProjectConfig): Promise<void> {
} }
} }
export async function generateCloudflareWorkerTypes( export async function generateCloudflareWorkerTypes(config: ProjectConfig) {
config: ProjectConfig,
): Promise<void> {
if (config.runtime !== "workers") { if (config.runtime !== "workers") {
return; return;
} }
@@ -65,10 +63,7 @@ export async function generateCloudflareWorkerTypes(
} }
} }
async function setupBunRuntime( async function setupBunRuntime(serverDir: string, _backend: Backend) {
serverDir: string,
_backend: Backend,
): Promise<void> {
const packageJsonPath = path.join(serverDir, "package.json"); const packageJsonPath = path.join(serverDir, "package.json");
if (!(await fs.pathExists(packageJsonPath))) return; if (!(await fs.pathExists(packageJsonPath))) return;
@@ -88,10 +83,7 @@ async function setupBunRuntime(
}); });
} }
async function setupNodeRuntime( async function setupNodeRuntime(serverDir: string, backend: Backend) {
serverDir: string,
backend: Backend,
): Promise<void> {
const packageJsonPath = path.join(serverDir, "package.json"); const packageJsonPath = path.join(serverDir, "package.json");
if (!(await fs.pathExists(packageJsonPath))) return; if (!(await fs.pathExists(packageJsonPath))) return;
@@ -123,7 +115,7 @@ async function setupNodeRuntime(
} }
} }
async function setupWorkersRuntime(serverDir: string): Promise<void> { async function setupWorkersRuntime(serverDir: string) {
const packageJsonPath = path.join(serverDir, "package.json"); const packageJsonPath = path.join(serverDir, "package.json");
if (!(await fs.pathExists(packageJsonPath))) return; if (!(await fs.pathExists(packageJsonPath))) return;

View File

@@ -6,7 +6,7 @@ import pc from "picocolors";
import type { ProjectConfig } from "../../types"; import type { ProjectConfig } from "../../types";
import { getPackageExecutionCommand } from "../../utils/package-runner"; import { getPackageExecutionCommand } from "../../utils/package-runner";
export async function setupStarlight(config: ProjectConfig): Promise<void> { export async function setupStarlight(config: ProjectConfig) {
const { packageManager, projectDir } = config; const { packageManager, projectDir } = config;
const s = spinner(); const s = spinner();

View File

@@ -8,7 +8,7 @@ import type { ProjectConfig } from "../../types";
import { addPackageDependency } from "../../utils/add-package-deps"; import { addPackageDependency } from "../../utils/add-package-deps";
import { getPackageExecutionCommand } from "../../utils/package-runner"; import { getPackageExecutionCommand } from "../../utils/package-runner";
export async function setupTauri(config: ProjectConfig): Promise<void> { export async function setupTauri(config: ProjectConfig) {
const { packageManager, frontend, projectDir } = config; const { packageManager, frontend, projectDir } = config;
const s = spinner(); const s = spinner();
const clientPackageDir = path.join(projectDir, "apps/web"); const clientPackageDir = path.join(projectDir, "apps/web");

View File

@@ -0,0 +1,141 @@
import { cancel, isCancel, log, multiselect } from "@clack/prompts";
import { execa } from "execa";
import pc from "picocolors";
import type { ProjectConfig } from "../../types";
import { addPackageDependency } from "../../utils/add-package-deps";
import { getPackageExecutionCommand } from "../../utils/package-runner";
import { setupBiome } from "./addons-setup";
type UltraciteEditor = "vscode" | "zed";
type UltraciteRule =
| "vscode-copilot"
| "cursor"
| "windsurf"
| "zed"
| "claude"
| "codex";
const EDITORS = {
vscode: {
label: "VSCode / Cursor / Windsurf",
hint: "Visual Studio Code editor configuration",
},
zed: {
label: "Zed",
hint: "Zed editor configuration",
},
} as const;
const RULES = {
"vscode-copilot": {
label: "VS Code Copilot",
hint: "GitHub Copilot integration for VS Code",
},
cursor: {
label: "Cursor",
hint: "Cursor AI editor configuration",
},
windsurf: {
label: "Windsurf",
hint: "Windsurf editor configuration",
},
zed: {
label: "Zed",
hint: "Zed editor rules",
},
claude: {
label: "Claude",
hint: "Claude AI integration",
},
codex: {
label: "Codex",
hint: "Codex AI integration",
},
} as const;
export async function setupUltracite(config: ProjectConfig, hasHusky: boolean) {
const { packageManager, projectDir } = config;
try {
log.info("Setting up Ultracite...");
await setupBiome(projectDir);
const editors = await multiselect<UltraciteEditor>({
message: "Choose editors",
options: Object.entries(EDITORS).map(([key, editor]) => ({
value: key as UltraciteEditor,
label: editor.label,
hint: editor.hint,
})),
required: false,
});
if (isCancel(editors)) {
cancel(pc.red("Operation cancelled"));
process.exit(0);
}
const rules = await multiselect<UltraciteRule>({
message: "Choose rules",
options: Object.entries(RULES).map(([key, rule]) => ({
value: key as UltraciteRule,
label: rule.label,
hint: rule.hint,
})),
required: false,
});
if (isCancel(rules)) {
cancel(pc.red("Operation cancelled"));
process.exit(0);
}
const ultraciteArgs = ["init", "--pm", packageManager];
if (editors.length > 0) {
ultraciteArgs.push("--editors", ...editors);
}
if (rules.length > 0) {
ultraciteArgs.push("--rules", ...rules);
}
if (hasHusky) {
ultraciteArgs.push("--features", "husky", "lint-staged");
}
const ultraciteArgsString = ultraciteArgs.join(" ");
const commandWithArgs = `ultracite@latest ${ultraciteArgsString} --skip-install`;
const ultraciteInitCommand = getPackageExecutionCommand(
packageManager,
commandWithArgs,
);
await execa(ultraciteInitCommand, {
cwd: projectDir,
env: { CI: "true" },
shell: true,
});
if (hasHusky) {
await addPackageDependency({
devDependencies: ["husky", "lint-staged"],
projectDir,
});
}
await addPackageDependency({
devDependencies: ["ultracite"],
projectDir,
});
log.success("Ultracite setup successfully!");
} catch (error) {
log.error(pc.red("Failed to set up Ultracite"));
if (error instanceof Error) {
console.error(pc.red(error.message));
}
}
}

View File

@@ -9,7 +9,7 @@ import { ensureArrayProperty, tsProject } from "../../utils/ts-morph";
export async function addPwaToViteConfig( export async function addPwaToViteConfig(
viteConfigPath: string, viteConfigPath: string,
projectName: string, projectName: string,
): Promise<void> { ) {
const sourceFile = tsProject.addSourceFileAtPathIfExists(viteConfigPath); const sourceFile = tsProject.addSourceFileAtPathIfExists(viteConfigPath);
if (!sourceFile) { if (!sourceFile) {
throw new Error("vite config not found"); throw new Error("vite config not found");

View File

@@ -7,7 +7,7 @@ import { setupSvelteWorkersDeploy } from "./workers-svelte-setup";
import { setupTanstackStartWorkersDeploy } from "./workers-tanstack-start-setup"; import { setupTanstackStartWorkersDeploy } from "./workers-tanstack-start-setup";
import { setupWorkersVitePlugin } from "./workers-vite-setup"; import { setupWorkersVitePlugin } from "./workers-vite-setup";
export async function setupWebDeploy(config: ProjectConfig): Promise<void> { export async function setupWebDeploy(config: ProjectConfig) {
const { webDeploy, frontend, projectDir } = config; const { webDeploy, frontend, projectDir } = config;
const { packageManager } = config; const { packageManager } = config;
@@ -39,7 +39,7 @@ export async function setupWebDeploy(config: ProjectConfig): Promise<void> {
async function setupWorkersWebDeploy( async function setupWorkersWebDeploy(
projectDir: string, projectDir: string,
pkgManager: PackageManager, pkgManager: PackageManager,
): Promise<void> { ) {
const webAppDir = path.join(projectDir, "apps/web"); const webAppDir = path.join(projectDir, "apps/web");
if (!(await fs.pathExists(webAppDir))) { if (!(await fs.pathExists(webAppDir))) {
@@ -65,7 +65,7 @@ async function setupWorkersWebDeploy(
async function setupNextWorkersDeploy( async function setupNextWorkersDeploy(
projectDir: string, projectDir: string,
_packageManager: PackageManager, _packageManager: PackageManager,
): Promise<void> { ) {
const webAppDir = path.join(projectDir, "apps/web"); const webAppDir = path.join(projectDir, "apps/web");
if (!(await fs.pathExists(webAppDir))) return; if (!(await fs.pathExists(webAppDir))) return;

View File

@@ -15,7 +15,7 @@ import { tsProject } from "../../utils/ts-morph";
export async function setupNuxtWorkersDeploy( export async function setupNuxtWorkersDeploy(
projectDir: string, projectDir: string,
packageManager: PackageManager, packageManager: PackageManager,
): Promise<void> { ) {
const webAppDir = path.join(projectDir, "apps/web"); const webAppDir = path.join(projectDir, "apps/web");
if (!(await fs.pathExists(webAppDir))) return; if (!(await fs.pathExists(webAppDir))) return;

View File

@@ -8,7 +8,7 @@ import { tsProject } from "../../utils/ts-morph";
export async function setupSvelteWorkersDeploy( export async function setupSvelteWorkersDeploy(
projectDir: string, projectDir: string,
packageManager: PackageManager, packageManager: PackageManager,
): Promise<void> { ) {
const webAppDir = path.join(projectDir, "apps/web"); const webAppDir = path.join(projectDir, "apps/web");
if (!(await fs.pathExists(webAppDir))) return; if (!(await fs.pathExists(webAppDir))) return;

View File

@@ -13,7 +13,7 @@ import { ensureArrayProperty, tsProject } from "../../utils/ts-morph";
export async function setupTanstackStartWorkersDeploy( export async function setupTanstackStartWorkersDeploy(
projectDir: string, projectDir: string,
packageManager: PackageManager, packageManager: PackageManager,
): Promise<void> { ) {
const webAppDir = path.join(projectDir, "apps/web"); const webAppDir = path.join(projectDir, "apps/web");
if (!(await fs.pathExists(webAppDir))) return; if (!(await fs.pathExists(webAppDir))) return;

View File

@@ -9,9 +9,7 @@ import {
import { addPackageDependency } from "../../utils/add-package-deps"; import { addPackageDependency } from "../../utils/add-package-deps";
import { ensureArrayProperty, tsProject } from "../../utils/ts-morph"; import { ensureArrayProperty, tsProject } from "../../utils/ts-morph";
export async function setupWorkersVitePlugin( export async function setupWorkersVitePlugin(projectDir: string) {
projectDir: string,
): Promise<void> {
const webAppDir = path.join(projectDir, "apps/web"); const webAppDir = path.join(projectDir, "apps/web");
const viteConfigPath = path.join(webAppDir, "vite.config.ts"); const viteConfigPath = path.join(webAppDir, "vite.config.ts");

View File

@@ -1,4 +1,4 @@
import { cancel, isCancel, multiselect } from "@clack/prompts"; import { cancel, groupMultiselect, isCancel } from "@clack/prompts";
import pc from "picocolors"; import pc from "picocolors";
import { DEFAULT_CONFIG } from "../constants"; import { DEFAULT_CONFIG } from "../constants";
import { type Addons, AddonsSchema, type Frontend } from "../types"; import { type Addons, AddonsSchema, type Frontend } from "../types";
@@ -13,45 +13,61 @@ type AddonOption = {
hint: string; hint: string;
}; };
function getAddonDisplay( function getAddonDisplay(addon: Addons): { label: string; hint: string } {
addon: Addons,
isRecommended = false,
): { label: string; hint: string } {
let label: string; let label: string;
let hint: string; let hint: string;
if (addon === "turborepo") { switch (addon) {
label = isRecommended ? "Turborepo (Recommended)" : "Turborepo"; case "turborepo":
hint = "High-performance build system for JavaScript and TypeScript"; label = "Turborepo";
} else if (addon === "pwa") { hint = "High-performance build system";
label = "PWA (Progressive Web App)"; break;
hint = "Make your app installable and work offline"; case "pwa":
} else if (addon === "tauri") { label = "PWA (Progressive Web App)";
label = isRecommended ? "Tauri Desktop App" : "Tauri"; hint = "Make your app installable and work offline";
hint = "Build native desktop apps from your web frontend"; break;
} else if (addon === "biome") { case "tauri":
label = "Biome"; label = "Tauri";
hint = isRecommended hint = "Build native desktop apps from your web frontend";
? "Add Biome for linting and formatting" break;
: "Fast formatter and linter for JavaScript, TypeScript, JSX"; case "biome":
} else if (addon === "husky") { label = "Biome";
label = "Husky"; hint = "Format, lint, and more";
hint = isRecommended break;
? "Add Git hooks with Husky, lint-staged (requires Biome)" case "oxlint":
: "Git hooks made easy"; label = "Oxlint";
} else if (addon === "starlight") { hint = "Rust-powered linter";
label = "Starlight"; break;
hint = isRecommended case "ultracite":
? "Add Astro Starlight documentation site" label = "Ultracite";
: "Documentation site with Astro"; hint = "Zero-config Biome preset with AI integration";
} else { break;
label = addon; case "husky":
hint = `Add ${addon}`; label = "Husky";
hint = "Modern native Git hooks made easy";
break;
case "starlight":
label = "Starlight";
hint = "Build stellar docs with astro";
break;
case "fumadocs":
label = "Fumadocs";
hint = "Build excellent documentation site";
break;
default:
label = addon;
hint = `Add ${addon}`;
} }
return { label, hint }; return { label, hint };
} }
const ADDON_GROUPS = {
Documentation: ["starlight", "fumadocs"],
Linting: ["biome", "oxlint", "ultracite"],
Other: ["turborepo", "pwa", "tauri", "husky"],
};
export async function getAddonsChoice( export async function getAddonsChoice(
addons?: Addons[], addons?: Addons[],
frontends?: Frontend[], frontends?: Frontend[],
@@ -59,38 +75,48 @@ export async function getAddonsChoice(
if (addons !== undefined) return addons; if (addons !== undefined) return addons;
const allAddons = AddonsSchema.options.filter((addon) => addon !== "none"); const allAddons = AddonsSchema.options.filter((addon) => addon !== "none");
const groupedOptions: Record<string, AddonOption[]> = {
Documentation: [],
Linting: [],
Other: [],
};
const allPossibleOptions: AddonOption[] = []; const frontendsArray = frontends || [];
for (const addon of allAddons) { for (const addon of allAddons) {
const { isCompatible } = validateAddonCompatibility(addon, frontends || []); const { isCompatible } = validateAddonCompatibility(addon, frontendsArray);
if (!isCompatible) continue;
if (isCompatible) { const { label, hint } = getAddonDisplay(addon);
const { label, hint } = getAddonDisplay(addon, true); const option = { value: addon, label, hint };
allPossibleOptions.push({ if (ADDON_GROUPS.Documentation.includes(addon)) {
value: addon, groupedOptions.Documentation.push(option);
label, } else if (ADDON_GROUPS.Linting.includes(addon)) {
hint, groupedOptions.Linting.push(option);
}); } else if (ADDON_GROUPS.Other.includes(addon)) {
groupedOptions.Other.push(option);
} }
} }
const options = allPossibleOptions.sort((a, b) => { Object.keys(groupedOptions).forEach((group) => {
if (a.value === "turborepo") return -1; if (groupedOptions[group].length === 0) {
if (b.value === "turborepo") return 1; delete groupedOptions[group];
return 0; }
}); });
const initialValues = DEFAULT_CONFIG.addons.filter((addonValue) => const initialValues = DEFAULT_CONFIG.addons.filter((addonValue) =>
options.some((opt) => opt.value === addonValue), Object.values(groupedOptions).some((options) =>
options.some((opt) => opt.value === addonValue),
),
); );
const response = await multiselect({ const response = await groupMultiselect<Addons>({
message: "Select addons", message: "Select addons",
options: options, options: groupedOptions,
initialValues: initialValues, initialValues: initialValues,
required: false, required: false,
selectableGroups: false,
}); });
if (isCancel(response)) { if (isCancel(response)) {
@@ -98,10 +124,6 @@ export async function getAddonsChoice(
process.exit(0); process.exit(0);
} }
if (response.includes("husky") && !response.includes("biome")) {
response.push("biome");
}
return response; return response;
} }
@@ -109,34 +131,48 @@ export async function getAddonsToAdd(
frontend: Frontend[], frontend: Frontend[],
existingAddons: Addons[] = [], existingAddons: Addons[] = [],
): Promise<Addons[]> { ): Promise<Addons[]> {
const options: AddonOption[] = []; const groupedOptions: Record<string, AddonOption[]> = {
Documentation: [],
Linting: [],
Other: [],
};
const allAddons = AddonsSchema.options.filter((addon) => addon !== "none"); const frontendArray = frontend || [];
const compatibleAddons = getCompatibleAddons( const compatibleAddons = getCompatibleAddons(
allAddons, AddonsSchema.options.filter((addon) => addon !== "none"),
frontend, frontendArray,
existingAddons, existingAddons,
); );
for (const addon of compatibleAddons) { for (const addon of compatibleAddons) {
const { label, hint } = getAddonDisplay(addon, false); const { label, hint } = getAddonDisplay(addon);
const option = { value: addon, label, hint };
options.push({ if (ADDON_GROUPS.Documentation.includes(addon)) {
value: addon, groupedOptions.Documentation.push(option);
label, } else if (ADDON_GROUPS.Linting.includes(addon)) {
hint, groupedOptions.Linting.push(option);
}); } else if (ADDON_GROUPS.Other.includes(addon)) {
groupedOptions.Other.push(option);
}
} }
if (options.length === 0) { Object.keys(groupedOptions).forEach((group) => {
if (groupedOptions[group].length === 0) {
delete groupedOptions[group];
}
});
if (Object.keys(groupedOptions).length === 0) {
return []; return [];
} }
const response = await multiselect<Addons>({ const response = await groupMultiselect<Addons>({
message: "Select addons", message: "Select addons to add",
options: options, options: groupedOptions,
required: false, required: false,
selectableGroups: false,
}); });
if (isCancel(response)) { if (isCancel(response)) {

View File

@@ -10,7 +10,7 @@ export async function getFrontendChoice(
if (frontendOptions !== undefined) return frontendOptions; if (frontendOptions !== undefined) return frontendOptions;
const frontendTypes = await multiselect({ const frontendTypes = await multiselect({
message: "Select platforms to develop for", message: "Select project type",
options: [ options: [
{ {
value: "web", value: "web",

View File

@@ -39,7 +39,18 @@ export const FrontendSchema = z
export type Frontend = z.infer<typeof FrontendSchema>; export type Frontend = z.infer<typeof FrontendSchema>;
export const AddonsSchema = z export const AddonsSchema = z
.enum(["pwa", "tauri", "starlight", "biome", "husky", "turborepo", "none"]) .enum([
"pwa",
"tauri",
"starlight",
"biome",
"husky",
"turborepo",
"fumadocs",
"ultracite",
"oxlint",
"none",
])
.describe("Additional addons"); .describe("Additional addons");
export type Addons = z.infer<typeof AddonsSchema>; export type Addons = z.infer<typeof AddonsSchema>;

View File

@@ -7,7 +7,7 @@ export const addPackageDependency = async (opts: {
dependencies?: AvailableDependencies[]; dependencies?: AvailableDependencies[];
devDependencies?: AvailableDependencies[]; devDependencies?: AvailableDependencies[];
projectDir: string; projectDir: string;
}): Promise<void> => { }) => {
const { dependencies = [], devDependencies = [], projectDir } = opts; const { dependencies = [], devDependencies = [], projectDir } = opts;
const pkgJsonPath = path.join(projectDir, "package.json"); const pkgJsonPath = path.join(projectDir, "package.json");

View File

@@ -6,9 +6,7 @@ import { isTelemetryEnabled } from "./telemetry";
const POSTHOG_API_KEY = process.env.POSTHOG_API_KEY || ""; const POSTHOG_API_KEY = process.env.POSTHOG_API_KEY || "";
const POSTHOG_HOST = process.env.POSTHOG_HOST; const POSTHOG_HOST = process.env.POSTHOG_HOST;
export async function trackProjectCreation( export async function trackProjectCreation(config: ProjectConfig) {
config: ProjectConfig,
): Promise<void> {
const posthog = new PostHog(POSTHOG_API_KEY, { const posthog = new PostHog(POSTHOG_API_KEY, {
host: POSTHOG_HOST, host: POSTHOG_HOST,
flushAt: 1, flushAt: 1,

View File

@@ -6,9 +6,7 @@ import { getLatestCLIVersion } from "./get-latest-cli-version";
const BTS_CONFIG_FILE = "bts.jsonc"; const BTS_CONFIG_FILE = "bts.jsonc";
export async function writeBtsConfig( export async function writeBtsConfig(projectConfig: ProjectConfig) {
projectConfig: ProjectConfig,
): Promise<void> {
const btsConfig: BetterTStackConfig = { const btsConfig: BetterTStackConfig = {
version: getLatestCLIVersion(), version: getLatestCLIVersion(),
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
@@ -94,7 +92,7 @@ export async function readBtsConfig(
export async function updateBtsConfig( export async function updateBtsConfig(
projectDir: string, projectDir: string,
updates: Partial<Pick<BetterTStackConfig, "addons" | "webDeploy">>, updates: Partial<Pick<BetterTStackConfig, "addons" | "webDeploy">>,
): Promise<void> { ) {
try { try {
const configPath = path.join(projectDir, BTS_CONFIG_FILE); const configPath = path.join(projectDir, BTS_CONFIG_FILE);

View File

@@ -1,7 +1,7 @@
import { log } from "@clack/prompts"; import { log } from "@clack/prompts";
import { execa } from "execa"; import { execa } from "execa";
export async function openUrl(url: string): Promise<void> { export async function openUrl(url: string) {
const platform = process.platform; const platform = process.platform;
let command: string; let command: string;
let args: string[] = []; let args: string[] = [];

View File

@@ -34,7 +34,7 @@ export async function fetchSponsors(
return sponsors; return sponsors;
} }
export function displaySponsors(sponsors: SponsorEntry[]): void { export function displaySponsors(sponsors: SponsorEntry[]) {
if (sponsors.length === 0) { if (sponsors.length === 0) {
log.info("No sponsors found. You can be the first one! ✨"); log.info("No sponsors found. You can be the first one! ✨");
outro( outro(

View File

@@ -14,7 +14,7 @@ export async function processTemplate(
srcPath: string, srcPath: string,
destPath: string, destPath: string,
context: ProjectConfig, context: ProjectConfig,
): Promise<void> { ) {
try { try {
const templateContent = await fs.readFile(srcPath, "utf-8"); const templateContent = await fs.readFile(srcPath, "utf-8");
const template = handlebars.compile(templateContent); const template = handlebars.compile(templateContent);

View File

@@ -500,9 +500,7 @@ export function processAndValidateFlags(
return config; return config;
} }
export function validateConfigCompatibility( export function validateConfigCompatibility(config: Partial<ProjectConfig>) {
config: Partial<ProjectConfig>,
): void {
const effectiveDatabase = config.database; const effectiveDatabase = config.database;
const effectiveBackend = config.backend; const effectiveBackend = config.backend;
const effectiveFrontend = config.frontend; const effectiveFrontend = config.frontend;
@@ -607,11 +605,6 @@ export function validateConfigCompatibility(
process.exit(1); process.exit(1);
} }
if (config.addons.includes("husky") && !config.addons.includes("biome")) {
consola.warn(
"Husky addon is recommended to be used with Biome for lint-staged configuration.",
);
}
config.addons = [...new Set(config.addons)]; config.addons = [...new Set(config.addons)];
} }

View File

@@ -1,5 +1,5 @@
{ {
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json", "$schema": "https://biomejs.dev/schemas/2.1.2/schema.json",
"vcs": { "vcs": {
"enabled": false, "enabled": false,
"clientKind": "git", "clientKind": "git",
@@ -20,7 +20,8 @@
"!**/.nuxt", "!**/.nuxt",
"!bts.jsonc", "!bts.jsonc",
"!**/.expo", "!**/.expo",
"!**/.wrangler" "!**/.wrangler",
"!**/.source"
] ]
}, },
"formatter": { "formatter": {

View File

@@ -0,0 +1,22 @@
{
"$schema": "https://biomejs.dev/schemas/2.1.2/schema.json",
"files": {
"ignoreUnknown": false,
"includes": [
"**",
"!**/.next",
"!**/dist",
"!**/.turbo",
"!**/dev-dist",
"!**/.zed",
"!**/.vscode",
"!**/routeTree.gen.ts",
"!**/src-tauri",
"!**/.nuxt",
"!bts.jsonc",
"!**/.expo",
"!**/.wrangler",
"!**/.source"
]
}
}

View File

@@ -58,7 +58,7 @@ export default function SignInForm({
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
void form.handleSubmit(); form.handleSubmit();
}} }}
className="space-y-4" className="space-y-4"
> >

View File

@@ -61,7 +61,7 @@ export default function SignUpForm({
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
void form.handleSubmit(); form.handleSubmit();
}} }}
className="space-y-4" className="space-y-4"
> >

View File

@@ -58,7 +58,7 @@ export default function SignInForm({
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
void form.handleSubmit(); form.handleSubmit();
}} }}
className="space-y-4" className="space-y-4"
> >

View File

@@ -61,7 +61,7 @@ export default function SignUpForm({
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
void form.handleSubmit(); form.handleSubmit();
}} }}
className="space-y-4" className="space-y-4"
> >

View File

@@ -62,7 +62,7 @@ export default function SignInForm({
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
void form.handleSubmit(); form.handleSubmit();
}} }}
className="space-y-4" className="space-y-4"
> >

View File

@@ -65,7 +65,7 @@ export default function SignUpForm({
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
void form.handleSubmit(); form.handleSubmit();
}} }}
className="space-y-4" className="space-y-4"
> >

View File

@@ -62,7 +62,7 @@ export default function SignInForm({
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
void form.handleSubmit(); form.handleSubmit();
}} }}
className="space-y-4" className="space-y-4"
> >

View File

@@ -65,7 +65,7 @@ export default function SignUpForm({
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
void form.handleSubmit(); form.handleSubmit();
}} }}
className="space-y-4" className="space-y-4"
> >

View File

@@ -53,7 +53,7 @@ export default function SignInForm({
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
void form.handleSubmit(); form.handleSubmit();
}} }}
class="space-y-4" class="space-y-4"
> >

View File

@@ -56,7 +56,7 @@ export default function SignUpForm({
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
void form.handleSubmit(); form.handleSubmit();
}} }}
class="space-y-4" class="space-y-4"
> >

View File

@@ -0,0 +1,2 @@
[install]
linker = "isolated"

View File

@@ -10,17 +10,6 @@
"start": "vite", "start": "vite",
"check-types": "tsc --noEmit" "check-types": "tsc --noEmit"
}, },
"devDependencies": {
"@tanstack/react-router-devtools": "^1.114.27",
"@tanstack/router-plugin": "^1.114.27",
"@types/node": "^22.13.13",
"@types/react": "^19.0.12",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"postcss": "^8.5.3",
"tailwindcss": "^4.0.15",
"vite": "^6.2.2"
},
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.1.1", "@hookform/resolvers": "^5.1.1",
"radix-ui": "^1.4.2", "radix-ui": "^1.4.2",
@@ -37,5 +26,17 @@
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tw-animate-css": "^1.2.5", "tw-animate-css": "^1.2.5",
"zod": "^4.0.2" "zod": "^4.0.2"
},
"devDependencies": {
"@tanstack/react-router-devtools": "^1.114.27",
"@tanstack/router-plugin": "^1.114.27",
"@types/node": "^22.13.13",
"@types/react": "^19.0.12",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"postcss": "^8.5.3",
"typescript": "^5.8.3",
"tailwindcss": "^4.0.15",
"vite": "^6.2.2"
} }
} }

View File

@@ -30,6 +30,10 @@ const config = {
source: "/ingest/decide", source: "/ingest/decide",
destination: "https://us.i.posthog.com/decide", destination: "https://us.i.posthog.com/decide",
}, },
{
source: "/docs/:path*.mdx",
destination: "/llms.mdx/:path*",
},
]; ];
}, },
}; };

View File

@@ -0,0 +1,19 @@
<svg
fill="none"
height="48"
viewBox="0 0 117 118"
width="48"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-labelledby="ultraciteTitle"
>
<title id="ultraciteTitle">Ultracite</title>
<g fill="currentColor">
<path d="m73.4415 3.08447-11.5112-3.08447-9.7007 36.2036-8.7579-32.68485-11.5116 3.08447 9.4625 35.31358-23.5687-23.5686-8.42691 8.4269 25.85191 25.8521-32.19444-8.6265-3.08446 11.5112 35.1764 9.4256c-.4027-1.7371-.6158-3.5471-.6158-5.4068 0-13.1637 10.6713-23.8349 23.835-23.8349s23.8349 10.6712 23.8349 23.8349c0 1.8477-.2104 3.6466-.6082 5.3734l31.9687 8.566 3.084-11.5113-35.3158-9.463 32.1968-8.6271-3.085-11.5112-35.3147 9.4624 23.5686-23.5684-8.4269-8.42693-25.4933 25.49343z" />
<path d="m81.5886 65.0381c-.9869 4.1725-3.0705 7.9209-5.9294 10.9241l23.16 23.1603 8.4268-8.4269z" />
<path d="m75.4254 76.2044c-2.8935 2.9552-6.55 5.1606-10.6505 6.297l8.4275 31.4516 11.5113-3.084z" />
<path d="m64.345 82.6165c-1.9025.4891-3.8965.749-5.9514.749-2.2016 0-4.3335-.2985-6.3574-.8573l-8.4351 31.4808 11.5112 3.084z" />
<path d="m51.6292 82.3922c-4.0379-1.193-7.6294-3.4264-10.4637-6.3902l-23.217 23.2171 8.4269 8.4269z" />
<path d="m40.9741 75.7968c-2.7857-2.9824-4.8149-6.6808-5.7807-10.7889l-32.07328 8.5941 3.08444 11.5112z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,7 +1,11 @@
import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { execSync } from "node:child_process";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import Papa from "papaparse"; import Papa from "papaparse";
// TODO: write a more effiecient way of handling analytics
interface AnalyticsData { interface AnalyticsData {
date: string; date: string;
hour: number; hour: number;
@@ -36,7 +40,7 @@ interface ProcessedAnalyticsData {
totalRecords: number; totalRecords: number;
} }
async function generateAnalyticsData(): Promise<void> { async function generateAnalyticsData() {
try { try {
console.log("🔄 Fetching analytics data..."); console.log("🔄 Fetching analytics data...");
@@ -157,18 +161,29 @@ async function generateAnalyticsData(): Promise<void> {
totalRecords: processedData.length, totalRecords: processedData.length,
}; };
const publicDir = join(process.cwd(), "public"); console.log("📤 Uploading to Cloudflare R2...");
if (!existsSync(publicDir)) {
mkdirSync(publicDir, { recursive: true });
}
const outputPath = join(publicDir, "analytics-data.json"); const tempDir = mkdtempSync(join(tmpdir(), "analytics-"));
writeFileSync(outputPath, JSON.stringify(analyticsData, null, 2)); const tempFilePath = join(tempDir, "analytics-data.json");
writeFileSync(tempFilePath, JSON.stringify(analyticsData, null, 2));
const BUCKET_NAME = "bucket";
const key = "analytics-data.json";
const cmd = `npx wrangler r2 object put "${BUCKET_NAME}/${key}" --file="${tempFilePath}" --remote`;
console.log(`Uploading ${tempFilePath} to r2://${BUCKET_NAME}/${key} ...`);
try {
execSync(cmd, { stdio: "inherit" });
} catch (err) {
console.error("Failed to upload analytics data:", err);
throw err;
}
console.log( console.log(
`✅ Generated analytics data with ${processedData.length} records`, `✅ Generated analytics data with ${processedData.length} records`,
); );
console.log(`📁 Saved to: ${outputPath}`); console.log("📤 Uploaded to R2 bucket: bucket/analytics-data.json");
console.log(`🕒 Last data update: ${lastUpdated}`); console.log(`🕒 Last data update: ${lastUpdated}`);
} catch (error) { } catch (error) {
console.error("❌ Error generating analytics data:", error); console.error("❌ Error generating analytics data:", error);

View File

@@ -129,11 +129,15 @@ const getBadgeColors = (category: string): string => {
} }
}; };
const TechIcon: React.FC<{ function TechIcon({
icon,
name,
className,
}: {
icon: string; icon: string;
name: string; name: string;
className?: string; className?: string;
}> = ({ icon, name, className }) => { }) {
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const { theme } = useTheme(); const { theme } = useTheme();
@@ -168,7 +172,7 @@ const TechIcon: React.FC<{
{icon} {icon}
</span> </span>
); );
}; }
const getCategoryDisplayName = (categoryKey: string): string => { const getCategoryDisplayName = (categoryKey: string): string => {
const result = categoryKey.replace(/([A-Z])/g, " $1"); const result = categoryKey.replace(/([A-Z])/g, " $1");
@@ -754,10 +758,36 @@ const analyzeStackCompatibility = (stack: StackState): CompatibilityResult => {
if ( if (
nextStack.addons.includes("husky") && nextStack.addons.includes("husky") &&
!nextStack.addons.includes("biome") !nextStack.addons.includes("biome") &&
!nextStack.addons.includes("oxlint")
) { ) {
notes.addons.notes.push( notes.addons.notes.push(
"Husky addon is selected without Biome. Consider adding Biome for lint-staged integration.", "Husky addon is selected without a linter. Consider adding Biome or Oxlint for lint-staged integration.",
);
}
if (nextStack.addons.includes("ultracite")) {
if (nextStack.addons.includes("biome")) {
notes.addons.notes.push(
"Ultracite includes Biome setup. Biome addon will be removed.",
);
nextStack.addons = nextStack.addons.filter(
(addon) => addon !== "biome",
);
changed = true;
changes.push({
category: "addons",
message: "Biome addon removed (included in Ultracite)",
});
}
}
if (
nextStack.addons.includes("oxlint") &&
nextStack.addons.includes("biome")
) {
notes.addons.notes.push(
"Both Oxlint and Biome are selected. Consider using only one linter.",
); );
} }
@@ -967,7 +997,26 @@ const generateCommand = (stackState: StackState): string => {
if (!checkDefault("addons", stackState.addons)) { if (!checkDefault("addons", stackState.addons)) {
if (stackState.addons.length > 0) { if (stackState.addons.length > 0) {
flags.push(`--addons ${stackState.addons.join(" ")}`); const validAddons = stackState.addons.filter((addon) =>
[
"pwa",
"tauri",
"starlight",
"biome",
"husky",
"turborepo",
"ultracite",
"fumadocs",
"oxlint",
].includes(addon),
);
if (validAddons.length > 0) {
flags.push(`--addons ${validAddons.join(" ")}`);
} else {
if (DEFAULT_STACK.addons.length > 0) {
flags.push("--addons none");
}
}
} else { } else {
if (DEFAULT_STACK.addons.length > 0) { if (DEFAULT_STACK.addons.length > 0) {
flags.push("--addons none"); flags.push("--addons none");
@@ -1687,7 +1736,10 @@ const StackBuilder = () => {
<TechIcon <TechIcon
icon={tech.icon} icon={tech.icon}
name={tech.name} name={tech.name}
className="mr-1.5 h-3 w-3 sm:h-4 sm:w-4" className={cn(
"mr-1.5 h-3 w-3 sm:h-4 sm:w-4",
tech.className,
)}
/> />
)} )}
<span <span

View File

@@ -414,14 +414,14 @@ export default function AnalyticsPage() {
const loadAnalyticsData = useCallback(async () => { const loadAnalyticsData = useCallback(async () => {
try { try {
const response = await fetch("/analytics-data.json"); const response = await fetch("https://r2.amanv.dev/analytics-data.json");
const analyticsData = await response.json(); const analyticsData = await response.json();
setData(analyticsData.data || []); setData(analyticsData.data || []);
setLastUpdated(analyticsData.lastUpdated || null); setLastUpdated(analyticsData.lastUpdated || null);
console.log( console.log(
`Loaded ${analyticsData.data?.length || 0} records from static JSON`, `Loaded ${analyticsData.data?.length || 0} records from R2 bucket`,
); );
console.log(`Data generated at: ${analyticsData.generatedAt}`); console.log(`Data generated at: ${analyticsData.generatedAt}`);
} catch (error: unknown) { } catch (error: unknown) {

View File

@@ -0,0 +1,11 @@
import { getLLMText } from "@/lib/get-llm-text";
import { source } from "@/lib/source";
export const revalidate = false;
export async function GET() {
const scan = source.getPages().map(getLLMText);
const scanned = await Promise.all(scan);
return new Response(scanned.join("\n\n"));
}

View File

@@ -0,0 +1,21 @@
import { notFound } from "next/navigation";
import { type NextRequest, NextResponse } from "next/server";
import { getLLMText } from "@/lib/get-llm-text";
import { source } from "@/lib/source";
export const revalidate = false;
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ slug?: string[] }> },
) {
const { slug } = await params;
const page = source.getPage(slug);
if (!page) notFound();
return new NextResponse(await getLLMText(page));
}
export function generateStaticParams() {
return source.generateParams();
}

View File

@@ -1,4 +1,17 @@
export const TECH_OPTIONS = { import type { TechCategory } from "./types";
export const TECH_OPTIONS: Record<
TechCategory,
{
id: string;
name: string;
description: string;
icon: string;
color: string;
default?: boolean;
className?: string;
}[]
> = {
api: [ api: [
{ {
id: "trpc", id: "trpc",
@@ -97,6 +110,7 @@ export const TECH_OPTIONS = {
description: "Expo with NativeWind (Tailwind)", description: "Expo with NativeWind (Tailwind)",
icon: "/icon/expo.svg", icon: "/icon/expo.svg",
color: "from-purple-400 to-purple-600", color: "from-purple-400 to-purple-600",
className: "invert-0 dark:invert",
default: false, default: false,
}, },
{ {
@@ -105,6 +119,7 @@ export const TECH_OPTIONS = {
description: "Expo with Unistyles", description: "Expo with Unistyles",
icon: "/icon/expo.svg", icon: "/icon/expo.svg",
color: "from-pink-400 to-pink-600", color: "from-pink-400 to-pink-600",
className: "invert-0 dark:invert",
default: false, default: false,
}, },
{ {
@@ -368,6 +383,7 @@ export const TECH_OPTIONS = {
description: "Default package manager", description: "Default package manager",
icon: "/icon/npm.svg", icon: "/icon/npm.svg",
color: "from-red-500 to-red-700", color: "from-red-500 to-red-700",
className: "invert-0 dark:invert",
}, },
{ {
id: "pnpm", id: "pnpm",
@@ -388,8 +404,8 @@ export const TECH_OPTIONS = {
addons: [ addons: [
{ {
id: "pwa", id: "pwa",
name: "PWA", name: "PWA (Progressive Web App)",
description: "Progressive Web App", description: "Make your app installable and work offline",
icon: "", icon: "",
color: "from-blue-500 to-blue-700", color: "from-blue-500 to-blue-700",
default: false, default: false,
@@ -397,7 +413,7 @@ export const TECH_OPTIONS = {
{ {
id: "tauri", id: "tauri",
name: "Tauri", name: "Tauri",
description: "Desktop app support", description: "Build native desktop apps",
icon: "/icon/tauri.svg", icon: "/icon/tauri.svg",
color: "from-amber-500 to-amber-700", color: "from-amber-500 to-amber-700",
default: false, default: false,
@@ -405,7 +421,7 @@ export const TECH_OPTIONS = {
{ {
id: "starlight", id: "starlight",
name: "Starlight", name: "Starlight",
description: "Documentation site with Astro", description: "Build stellar docs with astro",
icon: "/icon/starlight.svg", icon: "/icon/starlight.svg",
color: "from-teal-500 to-teal-700", color: "from-teal-500 to-teal-700",
default: false, default: false,
@@ -413,7 +429,7 @@ export const TECH_OPTIONS = {
{ {
id: "biome", id: "biome",
name: "Biome", name: "Biome",
description: "Linting & formatting", description: "Format, lint, and more",
icon: "/icon/biome.svg", icon: "/icon/biome.svg",
color: "from-green-500 to-green-700", color: "from-green-500 to-green-700",
default: false, default: false,
@@ -421,15 +437,40 @@ export const TECH_OPTIONS = {
{ {
id: "husky", id: "husky",
name: "Husky", name: "Husky",
description: "Git hooks & lint-staged", description: "Modern native Git hooks made easy",
icon: "", icon: "",
color: "from-purple-500 to-purple-700", color: "from-purple-500 to-purple-700",
default: false, default: false,
}, },
{
id: "ultracite",
name: "Ultracite",
description: "Biome preset with AI integration",
icon: "/icon/ultracite.svg",
color: "from-blue-500 to-blue-700",
className: "invert-0 dark:invert",
default: false,
},
{
id: "fumadocs",
name: "Fumadocs",
description: "Build excellent documentation site",
icon: "",
color: "from-indigo-500 to-indigo-700",
default: false,
},
{
id: "oxlint",
name: "Oxlint",
description: "Rust-powered linter",
icon: "",
color: "from-orange-500 to-orange-700",
default: false,
},
{ {
id: "turborepo", id: "turborepo",
name: "Turborepo", name: "Turborepo",
description: "Monorepo build system", description: "High-performance build system",
icon: "/icon/turborepo.svg", icon: "/icon/turborepo.svg",
color: "from-gray-400 to-gray-700", color: "from-gray-400 to-gray-700",
default: true, default: true,

View File

@@ -0,0 +1,26 @@
import type { InferPageType } from "fumadocs-core/source";
import { remarkInclude } from "fumadocs-mdx/config";
import { remark } from "remark";
import remarkGfm from "remark-gfm";
import remarkMdx from "remark-mdx";
import type { source } from "@/lib/source";
const processor = remark()
.use(remarkMdx)
// needed for Fumadocs MDX
.use(remarkInclude)
.use(remarkGfm);
export async function getLLMText(page: InferPageType<typeof source>) {
const processed = await processor.process({
path: page.data._file.absolutePath,
value: page.data.content,
});
return `# ${page.data.title}
URL: ${page.url}
${page.data.description}
${processed.value}`;
}

View File

@@ -1,27 +1,19 @@
export type TechCategory = export type TechCategory =
| "core" | "api"
| "frontend" | "webFrontend"
| "nativeFrontend"
| "runtime"
| "backend" | "backend"
| "database" | "database"
| "auth"
| "orm" | "orm"
| "router"; | "dbSetup"
| "webDeploy"
export interface TechNode { | "auth"
id: string; | "packageManager"
type: string; | "addons"
position: { x: number; y: number }; | "examples"
data: { | "git"
label: string; | "install";
category: TechCategory;
description: string;
isDefault: boolean;
alternatives?: string[];
isActive: boolean;
group?: TechCategory;
isStatic?: boolean;
};
}
export interface TechEdge { export interface TechEdge {
id: string; id: string;

View File

@@ -14,7 +14,7 @@
}, },
"apps/cli": { "apps/cli": {
"name": "create-better-t-stack", "name": "create-better-t-stack",
"version": "2.26.5", "version": "2.27.1",
"bin": { "bin": {
"create-better-t-stack": "dist/index.js", "create-better-t-stack": "dist/index.js",
}, },