diff --git a/.changeset/sad-months-drop.md b/.changeset/sad-months-drop.md new file mode 100644 index 00000000..5344e603 --- /dev/null +++ b/.changeset/sad-months-drop.md @@ -0,0 +1,9 @@ +--- +"create-better-t-stack": minor +--- + +Added addons: fumadocs, ultracite, oxlint + +Added bunfig.toml with isolated linker + +Grouped addon prompts \ No newline at end of file diff --git a/apps/cli/src/constants.ts b/apps/cli/src/constants.ts index 8c630a09..4442cb3b 100644 --- a/apps/cli/src/constants.ts +++ b/apps/cli/src/constants.ts @@ -1,6 +1,6 @@ import path from "node:path"; 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"; const __filename = fileURLToPath(import.meta.url); @@ -55,10 +55,12 @@ export const dependencyVersionMap = { "@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", - "lint-staged": "^15.5.0", + "lint-staged": "^16.1.2", tsx: "^4.19.2", "@types/node": "^22.13.11", @@ -119,13 +121,16 @@ export const dependencyVersionMap = { export type AvailableDependencies = keyof typeof dependencyVersionMap; -export const ADDON_COMPATIBILITY = { +export const ADDON_COMPATIBILITY: Record = { pwa: ["tanstack-router", "react-router", "solid", "next"], tauri: ["tanstack-router", "react-router", "nuxt", "svelte", "solid"], biome: [], husky: [], turborepo: [], starlight: [], + ultracite: [], + oxlint: [], + fumadocs: [], none: [], } as const; diff --git a/apps/cli/src/helpers/database-providers/d1-setup.ts b/apps/cli/src/helpers/database-providers/d1-setup.ts index 6b9331f6..5143b3ab 100644 --- a/apps/cli/src/helpers/database-providers/d1-setup.ts +++ b/apps/cli/src/helpers/database-providers/d1-setup.ts @@ -5,7 +5,7 @@ import { type EnvVariable, } from "../project-generation/env-setup"; -export async function setupCloudflareD1(config: ProjectConfig): Promise { +export async function setupCloudflareD1(config: ProjectConfig) { const { projectDir } = config; const envPath = path.join(projectDir, "apps/server", ".env"); diff --git a/apps/cli/src/helpers/database-providers/docker-compose-setup.ts b/apps/cli/src/helpers/database-providers/docker-compose-setup.ts index f34fb62e..edee1a69 100644 --- a/apps/cli/src/helpers/database-providers/docker-compose-setup.ts +++ b/apps/cli/src/helpers/database-providers/docker-compose-setup.ts @@ -5,7 +5,7 @@ import { type EnvVariable, } from "../project-generation/env-setup"; -export async function setupDockerCompose(config: ProjectConfig): Promise { +export async function setupDockerCompose(config: ProjectConfig) { const { database, projectDir, projectName } = config; if (database === "none" || database === "sqlite") { diff --git a/apps/cli/src/helpers/database-providers/neon-setup.ts b/apps/cli/src/helpers/database-providers/neon-setup.ts index af57de9f..4e6ad1d4 100644 --- a/apps/cli/src/helpers/database-providers/neon-setup.ts +++ b/apps/cli/src/helpers/database-providers/neon-setup.ts @@ -155,7 +155,7 @@ function displayManualSetupInstructions() { DATABASE_URL="your_connection_string"`); } -export async function setupNeonPostgres(config: ProjectConfig): Promise { +export async function setupNeonPostgres(config: ProjectConfig) { const { packageManager, projectDir } = config; try { diff --git a/apps/cli/src/helpers/database-providers/turso-setup.ts b/apps/cli/src/helpers/database-providers/turso-setup.ts index 7a469cf8..4cbf7347 100644 --- a/apps/cli/src/helpers/database-providers/turso-setup.ts +++ b/apps/cli/src/helpers/database-providers/turso-setup.ts @@ -206,7 +206,7 @@ DATABASE_URL=your_database_url DATABASE_AUTH_TOKEN=your_auth_token`); } -export async function setupTurso(config: ProjectConfig): Promise { +export async function setupTurso(config: ProjectConfig) { const { orm, projectDir } = config; const _isDrizzle = orm === "drizzle"; const setupSpinner = spinner(); diff --git a/apps/cli/src/helpers/project-generation/add-addons.ts b/apps/cli/src/helpers/project-generation/add-addons.ts index c3cceb3a..e9304589 100644 --- a/apps/cli/src/helpers/project-generation/add-addons.ts +++ b/apps/cli/src/helpers/project-generation/add-addons.ts @@ -19,7 +19,7 @@ function exitWithError(message: string): never { export async function addAddonsToProject( input: AddInput & { addons: Addons[]; suppressInstallMessage?: boolean }, -): Promise { +) { try { const projectDir = input.projectDir || process.cwd(); diff --git a/apps/cli/src/helpers/project-generation/add-deployment.ts b/apps/cli/src/helpers/project-generation/add-deployment.ts index c189c7eb..8575b941 100644 --- a/apps/cli/src/helpers/project-generation/add-deployment.ts +++ b/apps/cli/src/helpers/project-generation/add-deployment.ts @@ -18,7 +18,7 @@ function exitWithError(message: string): never { export async function addDeploymentToProject( input: AddInput & { webDeploy: WebDeploy; suppressInstallMessage?: boolean }, -): Promise { +) { try { const projectDir = input.projectDir || process.cwd(); diff --git a/apps/cli/src/helpers/project-generation/command-handlers.ts b/apps/cli/src/helpers/project-generation/command-handlers.ts index 0013d984..d4f815ce 100644 --- a/apps/cli/src/helpers/project-generation/command-handlers.ts +++ b/apps/cli/src/helpers/project-generation/command-handlers.ts @@ -136,7 +136,7 @@ export async function createProjectHandler( } } -export async function addAddonsHandler(input: AddInput): Promise { +export async function addAddonsHandler(input: AddInput) { try { const projectDir = input.projectDir || process.cwd(); const detectedConfig = await detectProjectConfig(projectDir); diff --git a/apps/cli/src/helpers/project-generation/env-setup.ts b/apps/cli/src/helpers/project-generation/env-setup.ts index 89dbee23..210c4caf 100644 --- a/apps/cli/src/helpers/project-generation/env-setup.ts +++ b/apps/cli/src/helpers/project-generation/env-setup.ts @@ -12,7 +12,7 @@ export interface EnvVariable { export async function addEnvVariablesToFile( filePath: string, variables: EnvVariable[], -): Promise { +) { await fs.ensureDir(path.dirname(filePath)); let envContent = ""; @@ -84,9 +84,7 @@ export async function addEnvVariablesToFile( } } -export async function setupEnvironmentVariables( - config: ProjectConfig, -): Promise { +export async function setupEnvironmentVariables(config: ProjectConfig) { const { backend, frontend, database, auth, examples, dbSetup, projectDir } = config; diff --git a/apps/cli/src/helpers/project-generation/git.ts b/apps/cli/src/helpers/project-generation/git.ts index b7365a32..c1d59ee7 100644 --- a/apps/cli/src/helpers/project-generation/git.ts +++ b/apps/cli/src/helpers/project-generation/git.ts @@ -2,10 +2,7 @@ import { log } from "@clack/prompts"; import { $ } from "execa"; import pc from "picocolors"; -export async function initializeGit( - projectDir: string, - useGit: boolean, -): Promise { +export async function initializeGit(projectDir: string, useGit: boolean) { if (!useGit) return; const gitVersionResult = await $({ diff --git a/apps/cli/src/helpers/project-generation/post-installation.ts b/apps/cli/src/helpers/project-generation/post-installation.ts index d40bd3e5..a1eab3d4 100644 --- a/apps/cli/src/helpers/project-generation/post-installation.ts +++ b/apps/cli/src/helpers/project-generation/post-installation.ts @@ -142,6 +142,10 @@ export async function displayPostInstallInstructions( 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 (databaseInstructions) output += `\n${databaseInstructions.trim()}\n`; if (tauriInstructions) output += `\n${tauriInstructions.trim()}\n`; diff --git a/apps/cli/src/helpers/project-generation/project-config.ts b/apps/cli/src/helpers/project-generation/project-config.ts index 27ffed6d..65a53f4f 100644 --- a/apps/cli/src/helpers/project-generation/project-config.ts +++ b/apps/cli/src/helpers/project-generation/project-config.ts @@ -7,7 +7,7 @@ import type { ProjectConfig } from "../../types"; export async function updatePackageConfigurations( projectDir: string, options: ProjectConfig, -): Promise { +) { await updateRootPackageJson(projectDir, options); if (options.backend !== "convex") { await updateServerPackageJson(projectDir, options); @@ -19,7 +19,7 @@ export async function updatePackageConfigurations( async function updateRootPackageJson( projectDir: string, options: ProjectConfig, -): Promise { +) { const rootPackageJsonPath = path.join(projectDir, "package.json"); 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 { const { stdout } = await execa(options.packageManager, ["-v"], { cwd: projectDir, @@ -235,7 +223,7 @@ async function updateRootPackageJson( async function updateServerPackageJson( projectDir: string, options: ProjectConfig, -): Promise { +) { const serverPackageJsonPath = path.join( projectDir, "apps/server/package.json", @@ -287,7 +275,7 @@ async function updateServerPackageJson( async function updateConvexPackageJson( projectDir: string, options: ProjectConfig, -): Promise { +) { const convexPackageJsonPath = path.join( projectDir, "packages/backend/package.json", diff --git a/apps/cli/src/helpers/project-generation/template-manager.ts b/apps/cli/src/helpers/project-generation/template-manager.ts index bc7ad7b2..98cd3c68 100644 --- a/apps/cli/src/helpers/project-generation/template-manager.ts +++ b/apps/cli/src/helpers/project-generation/template-manager.ts @@ -11,7 +11,7 @@ async function processAndCopyFiles( destDir: string, context: ProjectConfig, overwrite = true, -): Promise { +) { const sourceFiles = await globby(sourcePattern, { cwd: baseSourceDir, dot: true, @@ -54,7 +54,7 @@ async function processAndCopyFiles( export async function copyBaseTemplate( projectDir: string, context: ProjectConfig, -): Promise { +) { const templateDir = path.join(PKG_ROOT, "templates/base"); await processAndCopyFiles(["**/*"], templateDir, projectDir, context); } @@ -62,7 +62,7 @@ export async function copyBaseTemplate( export async function setupFrontendTemplates( projectDir: string, context: ProjectConfig, -): Promise { +) { const hasReactWeb = context.frontend.some((f) => ["tanstack-router", "react-router", "tanstack-start", "next"].includes(f), ); @@ -241,7 +241,7 @@ export async function setupFrontendTemplates( export async function setupBackendFramework( projectDir: string, context: ProjectConfig, -): Promise { +) { if (context.backend === "none") { return; } @@ -332,7 +332,7 @@ export async function setupBackendFramework( export async function setupDbOrmTemplates( projectDir: string, context: ProjectConfig, -): Promise { +) { if ( context.backend === "convex" || context.orm === "none" || @@ -357,7 +357,7 @@ export async function setupDbOrmTemplates( export async function setupAuthTemplate( projectDir: string, context: ProjectConfig, -): Promise { +) { if (context.backend === "convex" || !context.auth) return; const serverAppDir = path.join(projectDir, "apps/server"); @@ -529,7 +529,7 @@ export async function setupAuthTemplate( export async function setupAddonsTemplate( projectDir: string, context: ProjectConfig, -): Promise { +) { if (!context.addons || context.addons.length === 0) return; for (const addon of context.addons) { @@ -567,7 +567,7 @@ export async function setupAddonsTemplate( export async function setupExamplesTemplate( projectDir: string, context: ProjectConfig, -): Promise { +) { if ( !context.examples || context.examples.length === 0 || @@ -773,10 +773,7 @@ export async function setupExamplesTemplate( } } -export async function handleExtras( - projectDir: string, - context: ProjectConfig, -): Promise { +export async function handleExtras(projectDir: string, context: ProjectConfig) { const extrasDir = path.join(PKG_ROOT, "templates/extras"); const hasNativeWind = context.frontend.includes("native-nativewind"); 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 ( context.packageManager === "pnpm" && (hasNative || context.frontend.includes("nuxt")) @@ -818,7 +823,7 @@ export async function handleExtras( export async function setupDockerComposeTemplates( projectDir: string, context: ProjectConfig, -): Promise { +) { if (context.dbSetup !== "docker" || context.database === "none") { return; } @@ -838,7 +843,7 @@ export async function setupDockerComposeTemplates( export async function setupDeploymentTemplates( projectDir: string, context: ProjectConfig, -): Promise { +) { if (context.webDeploy === "none") { return; } diff --git a/apps/cli/src/helpers/setup/addons-setup.ts b/apps/cli/src/helpers/setup/addons-setup.ts index 2a7acd2a..def58cc3 100644 --- a/apps/cli/src/helpers/setup/addons-setup.ts +++ b/apps/cli/src/helpers/setup/addons-setup.ts @@ -1,15 +1,19 @@ import path from "node:path"; import { log } from "@clack/prompts"; +import { execa } from "execa"; import fs from "fs-extra"; 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 { getPackageExecutionCommand } from "../../utils/package-runner"; +import { setupFumadocs } from "./fumadocs-setup"; import { setupStarlight } from "./starlight-setup"; import { setupTauri } from "./tauri-setup"; +import { setupUltracite } from "./ultracite-setup"; import { addPwaToViteConfig } from "./vite-pwa-setup"; export async function setupAddons(config: ProjectConfig, isAddCommand = false) { - const { addons, frontend, projectDir } = config; + const { addons, frontend, projectDir, packageManager } = config; const hasReactWebFrontend = frontend.includes("react-router") || frontend.includes("tanstack-router") || @@ -53,15 +57,37 @@ ${pc.cyan("Docs:")} ${pc.underline("https://turborepo.com/docs")} ) { await setupTauri(config); } - if (addons.includes("biome")) { - await setupBiome(projectDir); + const hasUltracite = addons.includes("ultracite"); + 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")) { await setupStarlight(config); } + if (addons.includes("fumadocs")) { + await setupFumadocs(config); + } } function getWebAppDir(projectDir: string, frontends: Frontend[]): string { @@ -77,7 +103,7 @@ function getWebAppDir(projectDir: string, frontends: Frontend[]): string { return path.join(projectDir, "apps/web"); } -async function setupBiome(projectDir: string) { +export async function setupBiome(projectDir: string) { await addPackageDependency({ devDependencies: ["@biomejs/biome"], 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({ devDependencies: ["husky", "lint-staged"], projectDir, @@ -111,11 +140,21 @@ async function setupHusky(projectDir: string) { prepare: "husky", }; - packageJson["lint-staged"] = { - "*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc}": [ - "biome check --write .", - ], - }; + if (linter === "oxlint") { + packageJson["lint-staged"] = { + "**/*.{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 }); } @@ -157,3 +196,33 @@ async function setupPwa(projectDir: string, frontends: Frontend[]) { 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, + }); +} diff --git a/apps/cli/src/helpers/setup/api-setup.ts b/apps/cli/src/helpers/setup/api-setup.ts index 058ec0a6..b1c2021e 100644 --- a/apps/cli/src/helpers/setup/api-setup.ts +++ b/apps/cli/src/helpers/setup/api-setup.ts @@ -4,7 +4,7 @@ import type { AvailableDependencies } from "../../constants"; import type { Frontend, ProjectConfig } from "../../types"; import { addPackageDependency } from "../../utils/add-package-deps"; -export async function setupApi(config: ProjectConfig): Promise { +export async function setupApi(config: ProjectConfig) { const { api, projectName, frontend, backend, packageManager, projectDir } = config; const isConvex = backend === "convex"; diff --git a/apps/cli/src/helpers/setup/auth-setup.ts b/apps/cli/src/helpers/setup/auth-setup.ts index 5a61b2e9..8d6c86bf 100644 --- a/apps/cli/src/helpers/setup/auth-setup.ts +++ b/apps/cli/src/helpers/setup/auth-setup.ts @@ -5,7 +5,7 @@ import pc from "picocolors"; import type { ProjectConfig } from "../../types"; import { addPackageDependency } from "../../utils/add-package-deps"; -export async function setupAuth(config: ProjectConfig): Promise { +export async function setupAuth(config: ProjectConfig) { const { auth, frontend, backend, projectDir } = config; if (backend === "convex" || !auth) { return; diff --git a/apps/cli/src/helpers/setup/backend-setup.ts b/apps/cli/src/helpers/setup/backend-setup.ts index e7afa256..0425042e 100644 --- a/apps/cli/src/helpers/setup/backend-setup.ts +++ b/apps/cli/src/helpers/setup/backend-setup.ts @@ -3,9 +3,7 @@ import type { AvailableDependencies } from "../../constants"; import type { ProjectConfig } from "../../types"; import { addPackageDependency } from "../../utils/add-package-deps"; -export async function setupBackendDependencies( - config: ProjectConfig, -): Promise { +export async function setupBackendDependencies(config: ProjectConfig) { const { backend, runtime, api, projectDir } = config; if (backend === "convex") { diff --git a/apps/cli/src/helpers/setup/db-setup.ts b/apps/cli/src/helpers/setup/db-setup.ts index ee5ac270..48d47e17 100644 --- a/apps/cli/src/helpers/setup/db-setup.ts +++ b/apps/cli/src/helpers/setup/db-setup.ts @@ -13,7 +13,7 @@ import { setupPrismaPostgres } from "../database-providers/prisma-postgres-setup import { setupSupabase } from "../database-providers/supabase-setup"; import { setupTurso } from "../database-providers/turso-setup"; -export async function setupDatabase(config: ProjectConfig): Promise { +export async function setupDatabase(config: ProjectConfig) { const { database, orm, dbSetup, backend, projectDir } = config; if (backend === "convex" || database === "none") { diff --git a/apps/cli/src/helpers/setup/examples-setup.ts b/apps/cli/src/helpers/setup/examples-setup.ts index 97f0b80d..2f7dde5b 100644 --- a/apps/cli/src/helpers/setup/examples-setup.ts +++ b/apps/cli/src/helpers/setup/examples-setup.ts @@ -4,7 +4,7 @@ import type { AvailableDependencies } from "../../constants"; import type { ProjectConfig } from "../../types"; import { addPackageDependency } from "../../utils/add-package-deps"; -export async function setupExamples(config: ProjectConfig): Promise { +export async function setupExamples(config: ProjectConfig) { const { examples, frontend, backend, projectDir } = config; if ( diff --git a/apps/cli/src/helpers/setup/fumadocs-setup.ts b/apps/cli/src/helpers/setup/fumadocs-setup.ts new file mode 100644 index 00000000..92c865d8 --- /dev/null +++ b/apps/cli/src/helpers/setup/fumadocs-setup.ts @@ -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({ + 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)); + } + } +} diff --git a/apps/cli/src/helpers/setup/runtime-setup.ts b/apps/cli/src/helpers/setup/runtime-setup.ts index 5c6f36a4..74736ba5 100644 --- a/apps/cli/src/helpers/setup/runtime-setup.ts +++ b/apps/cli/src/helpers/setup/runtime-setup.ts @@ -6,7 +6,7 @@ import pc from "picocolors"; import type { Backend, ProjectConfig } from "../../types"; import { addPackageDependency } from "../../utils/add-package-deps"; -export async function setupRuntime(config: ProjectConfig): Promise { +export async function setupRuntime(config: ProjectConfig) { const { runtime, backend, projectDir } = config; if (backend === "convex" || backend === "next" || runtime === "none") { @@ -28,9 +28,7 @@ export async function setupRuntime(config: ProjectConfig): Promise { } } -export async function generateCloudflareWorkerTypes( - config: ProjectConfig, -): Promise { +export async function generateCloudflareWorkerTypes(config: ProjectConfig) { if (config.runtime !== "workers") { return; } @@ -65,10 +63,7 @@ export async function generateCloudflareWorkerTypes( } } -async function setupBunRuntime( - serverDir: string, - _backend: Backend, -): Promise { +async function setupBunRuntime(serverDir: string, _backend: Backend) { const packageJsonPath = path.join(serverDir, "package.json"); if (!(await fs.pathExists(packageJsonPath))) return; @@ -88,10 +83,7 @@ async function setupBunRuntime( }); } -async function setupNodeRuntime( - serverDir: string, - backend: Backend, -): Promise { +async function setupNodeRuntime(serverDir: string, backend: Backend) { const packageJsonPath = path.join(serverDir, "package.json"); if (!(await fs.pathExists(packageJsonPath))) return; @@ -123,7 +115,7 @@ async function setupNodeRuntime( } } -async function setupWorkersRuntime(serverDir: string): Promise { +async function setupWorkersRuntime(serverDir: string) { const packageJsonPath = path.join(serverDir, "package.json"); if (!(await fs.pathExists(packageJsonPath))) return; diff --git a/apps/cli/src/helpers/setup/starlight-setup.ts b/apps/cli/src/helpers/setup/starlight-setup.ts index f8e8589b..c93af482 100644 --- a/apps/cli/src/helpers/setup/starlight-setup.ts +++ b/apps/cli/src/helpers/setup/starlight-setup.ts @@ -6,7 +6,7 @@ import pc from "picocolors"; import type { ProjectConfig } from "../../types"; import { getPackageExecutionCommand } from "../../utils/package-runner"; -export async function setupStarlight(config: ProjectConfig): Promise { +export async function setupStarlight(config: ProjectConfig) { const { packageManager, projectDir } = config; const s = spinner(); diff --git a/apps/cli/src/helpers/setup/tauri-setup.ts b/apps/cli/src/helpers/setup/tauri-setup.ts index 981ca0b5..9e542819 100644 --- a/apps/cli/src/helpers/setup/tauri-setup.ts +++ b/apps/cli/src/helpers/setup/tauri-setup.ts @@ -8,7 +8,7 @@ import type { ProjectConfig } from "../../types"; import { addPackageDependency } from "../../utils/add-package-deps"; import { getPackageExecutionCommand } from "../../utils/package-runner"; -export async function setupTauri(config: ProjectConfig): Promise { +export async function setupTauri(config: ProjectConfig) { const { packageManager, frontend, projectDir } = config; const s = spinner(); const clientPackageDir = path.join(projectDir, "apps/web"); diff --git a/apps/cli/src/helpers/setup/ultracite-setup.ts b/apps/cli/src/helpers/setup/ultracite-setup.ts new file mode 100644 index 00000000..1c18934d --- /dev/null +++ b/apps/cli/src/helpers/setup/ultracite-setup.ts @@ -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({ + 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({ + 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)); + } + } +} diff --git a/apps/cli/src/helpers/setup/vite-pwa-setup.ts b/apps/cli/src/helpers/setup/vite-pwa-setup.ts index 91aab655..4812c754 100644 --- a/apps/cli/src/helpers/setup/vite-pwa-setup.ts +++ b/apps/cli/src/helpers/setup/vite-pwa-setup.ts @@ -9,7 +9,7 @@ import { ensureArrayProperty, tsProject } from "../../utils/ts-morph"; export async function addPwaToViteConfig( viteConfigPath: string, projectName: string, -): Promise { +) { const sourceFile = tsProject.addSourceFileAtPathIfExists(viteConfigPath); if (!sourceFile) { throw new Error("vite config not found"); diff --git a/apps/cli/src/helpers/setup/web-deploy-setup.ts b/apps/cli/src/helpers/setup/web-deploy-setup.ts index 267cfba1..f82d3dbb 100644 --- a/apps/cli/src/helpers/setup/web-deploy-setup.ts +++ b/apps/cli/src/helpers/setup/web-deploy-setup.ts @@ -7,7 +7,7 @@ import { setupSvelteWorkersDeploy } from "./workers-svelte-setup"; import { setupTanstackStartWorkersDeploy } from "./workers-tanstack-start-setup"; import { setupWorkersVitePlugin } from "./workers-vite-setup"; -export async function setupWebDeploy(config: ProjectConfig): Promise { +export async function setupWebDeploy(config: ProjectConfig) { const { webDeploy, frontend, projectDir } = config; const { packageManager } = config; @@ -39,7 +39,7 @@ export async function setupWebDeploy(config: ProjectConfig): Promise { async function setupWorkersWebDeploy( projectDir: string, pkgManager: PackageManager, -): Promise { +) { const webAppDir = path.join(projectDir, "apps/web"); if (!(await fs.pathExists(webAppDir))) { @@ -65,7 +65,7 @@ async function setupWorkersWebDeploy( async function setupNextWorkersDeploy( projectDir: string, _packageManager: PackageManager, -): Promise { +) { const webAppDir = path.join(projectDir, "apps/web"); if (!(await fs.pathExists(webAppDir))) return; diff --git a/apps/cli/src/helpers/setup/workers-nuxt-setup.ts b/apps/cli/src/helpers/setup/workers-nuxt-setup.ts index 74690bc5..03457c5f 100644 --- a/apps/cli/src/helpers/setup/workers-nuxt-setup.ts +++ b/apps/cli/src/helpers/setup/workers-nuxt-setup.ts @@ -15,7 +15,7 @@ import { tsProject } from "../../utils/ts-morph"; export async function setupNuxtWorkersDeploy( projectDir: string, packageManager: PackageManager, -): Promise { +) { const webAppDir = path.join(projectDir, "apps/web"); if (!(await fs.pathExists(webAppDir))) return; diff --git a/apps/cli/src/helpers/setup/workers-svelte-setup.ts b/apps/cli/src/helpers/setup/workers-svelte-setup.ts index 19e7f4ea..d77c8d53 100644 --- a/apps/cli/src/helpers/setup/workers-svelte-setup.ts +++ b/apps/cli/src/helpers/setup/workers-svelte-setup.ts @@ -8,7 +8,7 @@ import { tsProject } from "../../utils/ts-morph"; export async function setupSvelteWorkersDeploy( projectDir: string, packageManager: PackageManager, -): Promise { +) { const webAppDir = path.join(projectDir, "apps/web"); if (!(await fs.pathExists(webAppDir))) return; diff --git a/apps/cli/src/helpers/setup/workers-tanstack-start-setup.ts b/apps/cli/src/helpers/setup/workers-tanstack-start-setup.ts index 4d009939..721a798b 100644 --- a/apps/cli/src/helpers/setup/workers-tanstack-start-setup.ts +++ b/apps/cli/src/helpers/setup/workers-tanstack-start-setup.ts @@ -13,7 +13,7 @@ import { ensureArrayProperty, tsProject } from "../../utils/ts-morph"; export async function setupTanstackStartWorkersDeploy( projectDir: string, packageManager: PackageManager, -): Promise { +) { const webAppDir = path.join(projectDir, "apps/web"); if (!(await fs.pathExists(webAppDir))) return; diff --git a/apps/cli/src/helpers/setup/workers-vite-setup.ts b/apps/cli/src/helpers/setup/workers-vite-setup.ts index 725eb75d..9ba55903 100644 --- a/apps/cli/src/helpers/setup/workers-vite-setup.ts +++ b/apps/cli/src/helpers/setup/workers-vite-setup.ts @@ -9,9 +9,7 @@ import { import { addPackageDependency } from "../../utils/add-package-deps"; import { ensureArrayProperty, tsProject } from "../../utils/ts-morph"; -export async function setupWorkersVitePlugin( - projectDir: string, -): Promise { +export async function setupWorkersVitePlugin(projectDir: string) { const webAppDir = path.join(projectDir, "apps/web"); const viteConfigPath = path.join(webAppDir, "vite.config.ts"); diff --git a/apps/cli/src/prompts/addons.ts b/apps/cli/src/prompts/addons.ts index 6cc66238..05f565a5 100644 --- a/apps/cli/src/prompts/addons.ts +++ b/apps/cli/src/prompts/addons.ts @@ -1,4 +1,4 @@ -import { cancel, isCancel, multiselect } from "@clack/prompts"; +import { cancel, groupMultiselect, isCancel } from "@clack/prompts"; import pc from "picocolors"; import { DEFAULT_CONFIG } from "../constants"; import { type Addons, AddonsSchema, type Frontend } from "../types"; @@ -13,45 +13,61 @@ type AddonOption = { hint: string; }; -function getAddonDisplay( - addon: Addons, - isRecommended = false, -): { label: string; hint: string } { +function getAddonDisplay(addon: Addons): { label: string; hint: string } { let label: string; let hint: string; - if (addon === "turborepo") { - label = isRecommended ? "Turborepo (Recommended)" : "Turborepo"; - hint = "High-performance build system for JavaScript and TypeScript"; - } else if (addon === "pwa") { - label = "PWA (Progressive Web App)"; - hint = "Make your app installable and work offline"; - } else if (addon === "tauri") { - label = isRecommended ? "Tauri Desktop App" : "Tauri"; - hint = "Build native desktop apps from your web frontend"; - } else if (addon === "biome") { - label = "Biome"; - hint = isRecommended - ? "Add Biome for linting and formatting" - : "Fast formatter and linter for JavaScript, TypeScript, JSX"; - } else if (addon === "husky") { - label = "Husky"; - hint = isRecommended - ? "Add Git hooks with Husky, lint-staged (requires Biome)" - : "Git hooks made easy"; - } else if (addon === "starlight") { - label = "Starlight"; - hint = isRecommended - ? "Add Astro Starlight documentation site" - : "Documentation site with Astro"; - } else { - label = addon; - hint = `Add ${addon}`; + switch (addon) { + case "turborepo": + label = "Turborepo"; + hint = "High-performance build system"; + break; + case "pwa": + label = "PWA (Progressive Web App)"; + hint = "Make your app installable and work offline"; + break; + case "tauri": + label = "Tauri"; + hint = "Build native desktop apps from your web frontend"; + break; + case "biome": + label = "Biome"; + hint = "Format, lint, and more"; + break; + case "oxlint": + label = "Oxlint"; + hint = "Rust-powered linter"; + break; + case "ultracite": + label = "Ultracite"; + hint = "Zero-config Biome preset with AI integration"; + break; + case "husky": + 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 }; } +const ADDON_GROUPS = { + Documentation: ["starlight", "fumadocs"], + Linting: ["biome", "oxlint", "ultracite"], + Other: ["turborepo", "pwa", "tauri", "husky"], +}; + export async function getAddonsChoice( addons?: Addons[], frontends?: Frontend[], @@ -59,38 +75,48 @@ export async function getAddonsChoice( if (addons !== undefined) return addons; const allAddons = AddonsSchema.options.filter((addon) => addon !== "none"); + const groupedOptions: Record = { + Documentation: [], + Linting: [], + Other: [], + }; - const allPossibleOptions: AddonOption[] = []; + const frontendsArray = frontends || []; for (const addon of allAddons) { - const { isCompatible } = validateAddonCompatibility(addon, frontends || []); - - if (isCompatible) { - const { label, hint } = getAddonDisplay(addon, true); - - allPossibleOptions.push({ - value: addon, - label, - hint, - }); + const { isCompatible } = validateAddonCompatibility(addon, frontendsArray); + if (!isCompatible) continue; + + const { label, hint } = getAddonDisplay(addon); + const option = { value: addon, label, hint }; + + if (ADDON_GROUPS.Documentation.includes(addon)) { + groupedOptions.Documentation.push(option); + } else if (ADDON_GROUPS.Linting.includes(addon)) { + groupedOptions.Linting.push(option); + } else if (ADDON_GROUPS.Other.includes(addon)) { + groupedOptions.Other.push(option); } } - const options = allPossibleOptions.sort((a, b) => { - if (a.value === "turborepo") return -1; - if (b.value === "turborepo") return 1; - return 0; + Object.keys(groupedOptions).forEach((group) => { + if (groupedOptions[group].length === 0) { + delete groupedOptions[group]; + } }); 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({ message: "Select addons", - options: options, + options: groupedOptions, initialValues: initialValues, required: false, + selectableGroups: false, }); if (isCancel(response)) { @@ -98,10 +124,6 @@ export async function getAddonsChoice( process.exit(0); } - if (response.includes("husky") && !response.includes("biome")) { - response.push("biome"); - } - return response; } @@ -109,34 +131,48 @@ export async function getAddonsToAdd( frontend: Frontend[], existingAddons: Addons[] = [], ): Promise { - const options: AddonOption[] = []; + const groupedOptions: Record = { + Documentation: [], + Linting: [], + Other: [], + }; - const allAddons = AddonsSchema.options.filter((addon) => addon !== "none"); + const frontendArray = frontend || []; const compatibleAddons = getCompatibleAddons( - allAddons, - frontend, + AddonsSchema.options.filter((addon) => addon !== "none"), + frontendArray, existingAddons, ); for (const addon of compatibleAddons) { - const { label, hint } = getAddonDisplay(addon, false); - - options.push({ - value: addon, - label, - hint, - }); + const { label, hint } = getAddonDisplay(addon); + const option = { value: addon, label, hint }; + + if (ADDON_GROUPS.Documentation.includes(addon)) { + groupedOptions.Documentation.push(option); + } else if (ADDON_GROUPS.Linting.includes(addon)) { + 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 []; } - const response = await multiselect({ - message: "Select addons", - options: options, + const response = await groupMultiselect({ + message: "Select addons to add", + options: groupedOptions, required: false, + selectableGroups: false, }); if (isCancel(response)) { diff --git a/apps/cli/src/prompts/frontend.ts b/apps/cli/src/prompts/frontend.ts index fd592bd9..5b532352 100644 --- a/apps/cli/src/prompts/frontend.ts +++ b/apps/cli/src/prompts/frontend.ts @@ -10,7 +10,7 @@ export async function getFrontendChoice( if (frontendOptions !== undefined) return frontendOptions; const frontendTypes = await multiselect({ - message: "Select platforms to develop for", + message: "Select project type", options: [ { value: "web", diff --git a/apps/cli/src/types.ts b/apps/cli/src/types.ts index 54e34c57..536a1748 100644 --- a/apps/cli/src/types.ts +++ b/apps/cli/src/types.ts @@ -39,7 +39,18 @@ export const FrontendSchema = z export type Frontend = z.infer; 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"); export type Addons = z.infer; diff --git a/apps/cli/src/utils/add-package-deps.ts b/apps/cli/src/utils/add-package-deps.ts index e83a176e..6e1479cd 100644 --- a/apps/cli/src/utils/add-package-deps.ts +++ b/apps/cli/src/utils/add-package-deps.ts @@ -7,7 +7,7 @@ export const addPackageDependency = async (opts: { dependencies?: AvailableDependencies[]; devDependencies?: AvailableDependencies[]; projectDir: string; -}): Promise => { +}) => { const { dependencies = [], devDependencies = [], projectDir } = opts; const pkgJsonPath = path.join(projectDir, "package.json"); diff --git a/apps/cli/src/utils/analytics.ts b/apps/cli/src/utils/analytics.ts index e515e43f..94bbb965 100644 --- a/apps/cli/src/utils/analytics.ts +++ b/apps/cli/src/utils/analytics.ts @@ -6,9 +6,7 @@ import { isTelemetryEnabled } from "./telemetry"; const POSTHOG_API_KEY = process.env.POSTHOG_API_KEY || ""; const POSTHOG_HOST = process.env.POSTHOG_HOST; -export async function trackProjectCreation( - config: ProjectConfig, -): Promise { +export async function trackProjectCreation(config: ProjectConfig) { const posthog = new PostHog(POSTHOG_API_KEY, { host: POSTHOG_HOST, flushAt: 1, diff --git a/apps/cli/src/utils/bts-config.ts b/apps/cli/src/utils/bts-config.ts index df167e96..01ca8d45 100644 --- a/apps/cli/src/utils/bts-config.ts +++ b/apps/cli/src/utils/bts-config.ts @@ -6,9 +6,7 @@ import { getLatestCLIVersion } from "./get-latest-cli-version"; const BTS_CONFIG_FILE = "bts.jsonc"; -export async function writeBtsConfig( - projectConfig: ProjectConfig, -): Promise { +export async function writeBtsConfig(projectConfig: ProjectConfig) { const btsConfig: BetterTStackConfig = { version: getLatestCLIVersion(), createdAt: new Date().toISOString(), @@ -94,7 +92,7 @@ export async function readBtsConfig( export async function updateBtsConfig( projectDir: string, updates: Partial>, -): Promise { +) { try { const configPath = path.join(projectDir, BTS_CONFIG_FILE); diff --git a/apps/cli/src/utils/open-url.ts b/apps/cli/src/utils/open-url.ts index b05d5fcd..541eb2c2 100644 --- a/apps/cli/src/utils/open-url.ts +++ b/apps/cli/src/utils/open-url.ts @@ -1,7 +1,7 @@ import { log } from "@clack/prompts"; import { execa } from "execa"; -export async function openUrl(url: string): Promise { +export async function openUrl(url: string) { const platform = process.platform; let command: string; let args: string[] = []; diff --git a/apps/cli/src/utils/sponsors.ts b/apps/cli/src/utils/sponsors.ts index 1f91b9a8..a61fd573 100644 --- a/apps/cli/src/utils/sponsors.ts +++ b/apps/cli/src/utils/sponsors.ts @@ -34,7 +34,7 @@ export async function fetchSponsors( return sponsors; } -export function displaySponsors(sponsors: SponsorEntry[]): void { +export function displaySponsors(sponsors: SponsorEntry[]) { if (sponsors.length === 0) { log.info("No sponsors found. You can be the first one! ✨"); outro( diff --git a/apps/cli/src/utils/template-processor.ts b/apps/cli/src/utils/template-processor.ts index 7d1d2cee..4fa7bfae 100644 --- a/apps/cli/src/utils/template-processor.ts +++ b/apps/cli/src/utils/template-processor.ts @@ -14,7 +14,7 @@ export async function processTemplate( srcPath: string, destPath: string, context: ProjectConfig, -): Promise { +) { try { const templateContent = await fs.readFile(srcPath, "utf-8"); const template = handlebars.compile(templateContent); diff --git a/apps/cli/src/validation.ts b/apps/cli/src/validation.ts index 607b3478..4bf27f37 100644 --- a/apps/cli/src/validation.ts +++ b/apps/cli/src/validation.ts @@ -500,9 +500,7 @@ export function processAndValidateFlags( return config; } -export function validateConfigCompatibility( - config: Partial, -): void { +export function validateConfigCompatibility(config: Partial) { const effectiveDatabase = config.database; const effectiveBackend = config.backend; const effectiveFrontend = config.frontend; @@ -607,11 +605,6 @@ export function validateConfigCompatibility( 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)]; } diff --git a/apps/cli/templates/addons/biome/biome.json.hbs b/apps/cli/templates/addons/biome/biome.json.hbs index aee61767..17649614 100644 --- a/apps/cli/templates/addons/biome/biome.json.hbs +++ b/apps/cli/templates/addons/biome/biome.json.hbs @@ -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": { "enabled": false, "clientKind": "git", @@ -20,7 +20,8 @@ "!**/.nuxt", "!bts.jsonc", "!**/.expo", - "!**/.wrangler" + "!**/.wrangler", + "!**/.source" ] }, "formatter": { diff --git a/apps/cli/templates/addons/ultracite/biome.json.hbs b/apps/cli/templates/addons/ultracite/biome.json.hbs new file mode 100644 index 00000000..2ed32a4c --- /dev/null +++ b/apps/cli/templates/addons/ultracite/biome.json.hbs @@ -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" + ] + } +} diff --git a/apps/cli/templates/auth/web/react/next/src/components/sign-in-form.tsx b/apps/cli/templates/auth/web/react/next/src/components/sign-in-form.tsx index 0a1ce434..3b8861ae 100644 --- a/apps/cli/templates/auth/web/react/next/src/components/sign-in-form.tsx +++ b/apps/cli/templates/auth/web/react/next/src/components/sign-in-form.tsx @@ -58,7 +58,7 @@ export default function SignInForm({ onSubmit={(e) => { e.preventDefault(); e.stopPropagation(); - void form.handleSubmit(); + form.handleSubmit(); }} className="space-y-4" > diff --git a/apps/cli/templates/auth/web/react/next/src/components/sign-up-form.tsx b/apps/cli/templates/auth/web/react/next/src/components/sign-up-form.tsx index 44889b9f..ce436caf 100644 --- a/apps/cli/templates/auth/web/react/next/src/components/sign-up-form.tsx +++ b/apps/cli/templates/auth/web/react/next/src/components/sign-up-form.tsx @@ -61,7 +61,7 @@ export default function SignUpForm({ onSubmit={(e) => { e.preventDefault(); e.stopPropagation(); - void form.handleSubmit(); + form.handleSubmit(); }} className="space-y-4" > diff --git a/apps/cli/templates/auth/web/react/react-router/src/components/sign-in-form.tsx b/apps/cli/templates/auth/web/react/react-router/src/components/sign-in-form.tsx index e06213a9..f5b7edb3 100644 --- a/apps/cli/templates/auth/web/react/react-router/src/components/sign-in-form.tsx +++ b/apps/cli/templates/auth/web/react/react-router/src/components/sign-in-form.tsx @@ -58,7 +58,7 @@ export default function SignInForm({ onSubmit={(e) => { e.preventDefault(); e.stopPropagation(); - void form.handleSubmit(); + form.handleSubmit(); }} className="space-y-4" > diff --git a/apps/cli/templates/auth/web/react/react-router/src/components/sign-up-form.tsx b/apps/cli/templates/auth/web/react/react-router/src/components/sign-up-form.tsx index 587beb43..97f1cddb 100644 --- a/apps/cli/templates/auth/web/react/react-router/src/components/sign-up-form.tsx +++ b/apps/cli/templates/auth/web/react/react-router/src/components/sign-up-form.tsx @@ -61,7 +61,7 @@ export default function SignUpForm({ onSubmit={(e) => { e.preventDefault(); e.stopPropagation(); - void form.handleSubmit(); + form.handleSubmit(); }} className="space-y-4" > diff --git a/apps/cli/templates/auth/web/react/tanstack-router/src/components/sign-in-form.tsx b/apps/cli/templates/auth/web/react/tanstack-router/src/components/sign-in-form.tsx index 730a8f23..65bfab25 100644 --- a/apps/cli/templates/auth/web/react/tanstack-router/src/components/sign-in-form.tsx +++ b/apps/cli/templates/auth/web/react/tanstack-router/src/components/sign-in-form.tsx @@ -62,7 +62,7 @@ export default function SignInForm({ onSubmit={(e) => { e.preventDefault(); e.stopPropagation(); - void form.handleSubmit(); + form.handleSubmit(); }} className="space-y-4" > diff --git a/apps/cli/templates/auth/web/react/tanstack-router/src/components/sign-up-form.tsx b/apps/cli/templates/auth/web/react/tanstack-router/src/components/sign-up-form.tsx index 1b605004..3bd480f0 100644 --- a/apps/cli/templates/auth/web/react/tanstack-router/src/components/sign-up-form.tsx +++ b/apps/cli/templates/auth/web/react/tanstack-router/src/components/sign-up-form.tsx @@ -65,7 +65,7 @@ export default function SignUpForm({ onSubmit={(e) => { e.preventDefault(); e.stopPropagation(); - void form.handleSubmit(); + form.handleSubmit(); }} className="space-y-4" > diff --git a/apps/cli/templates/auth/web/react/tanstack-start/src/components/sign-in-form.tsx b/apps/cli/templates/auth/web/react/tanstack-start/src/components/sign-in-form.tsx index 730a8f23..65bfab25 100644 --- a/apps/cli/templates/auth/web/react/tanstack-start/src/components/sign-in-form.tsx +++ b/apps/cli/templates/auth/web/react/tanstack-start/src/components/sign-in-form.tsx @@ -62,7 +62,7 @@ export default function SignInForm({ onSubmit={(e) => { e.preventDefault(); e.stopPropagation(); - void form.handleSubmit(); + form.handleSubmit(); }} className="space-y-4" > diff --git a/apps/cli/templates/auth/web/react/tanstack-start/src/components/sign-up-form.tsx b/apps/cli/templates/auth/web/react/tanstack-start/src/components/sign-up-form.tsx index 1b605004..3bd480f0 100644 --- a/apps/cli/templates/auth/web/react/tanstack-start/src/components/sign-up-form.tsx +++ b/apps/cli/templates/auth/web/react/tanstack-start/src/components/sign-up-form.tsx @@ -65,7 +65,7 @@ export default function SignUpForm({ onSubmit={(e) => { e.preventDefault(); e.stopPropagation(); - void form.handleSubmit(); + form.handleSubmit(); }} className="space-y-4" > diff --git a/apps/cli/templates/auth/web/solid/src/components/sign-in-form.tsx b/apps/cli/templates/auth/web/solid/src/components/sign-in-form.tsx index 768f704c..6ae2f169 100644 --- a/apps/cli/templates/auth/web/solid/src/components/sign-in-form.tsx +++ b/apps/cli/templates/auth/web/solid/src/components/sign-in-form.tsx @@ -53,7 +53,7 @@ export default function SignInForm({ onSubmit={(e) => { e.preventDefault(); e.stopPropagation(); - void form.handleSubmit(); + form.handleSubmit(); }} class="space-y-4" > diff --git a/apps/cli/templates/auth/web/solid/src/components/sign-up-form.tsx b/apps/cli/templates/auth/web/solid/src/components/sign-up-form.tsx index d8f6853e..0ee8e326 100644 --- a/apps/cli/templates/auth/web/solid/src/components/sign-up-form.tsx +++ b/apps/cli/templates/auth/web/solid/src/components/sign-up-form.tsx @@ -56,7 +56,7 @@ export default function SignUpForm({ onSubmit={(e) => { e.preventDefault(); e.stopPropagation(); - void form.handleSubmit(); + form.handleSubmit(); }} class="space-y-4" > diff --git a/apps/cli/templates/extras/bunfig.toml b/apps/cli/templates/extras/bunfig.toml new file mode 100644 index 00000000..22c04153 --- /dev/null +++ b/apps/cli/templates/extras/bunfig.toml @@ -0,0 +1,2 @@ +[install] +linker = "isolated" \ No newline at end of file diff --git a/apps/cli/templates/frontend/react/tanstack-router/package.json.hbs b/apps/cli/templates/frontend/react/tanstack-router/package.json.hbs index 34df8f76..325ba5db 100644 --- a/apps/cli/templates/frontend/react/tanstack-router/package.json.hbs +++ b/apps/cli/templates/frontend/react/tanstack-router/package.json.hbs @@ -10,17 +10,6 @@ "start": "vite", "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": { "@hookform/resolvers": "^5.1.1", "radix-ui": "^1.4.2", @@ -37,5 +26,17 @@ "tailwind-merge": "^3.3.1", "tw-animate-css": "^1.2.5", "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" } } diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index a297747a..d704c195 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -30,6 +30,10 @@ const config = { source: "/ingest/decide", destination: "https://us.i.posthog.com/decide", }, + { + source: "/docs/:path*.mdx", + destination: "/llms.mdx/:path*", + }, ]; }, }; diff --git a/apps/web/public/icon/ultracite.svg b/apps/web/public/icon/ultracite.svg new file mode 100644 index 00000000..bc64ca75 --- /dev/null +++ b/apps/web/public/icon/ultracite.svg @@ -0,0 +1,19 @@ + +Ultracite + + + + + + + + + diff --git a/apps/web/scripts/generate-analytics.ts b/apps/web/scripts/generate-analytics.ts index 29f18bd7..3ebcea6d 100644 --- a/apps/web/scripts/generate-analytics.ts +++ b/apps/web/scripts/generate-analytics.ts @@ -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 Papa from "papaparse"; +// TODO: write a more effiecient way of handling analytics + interface AnalyticsData { date: string; hour: number; @@ -36,7 +40,7 @@ interface ProcessedAnalyticsData { totalRecords: number; } -async function generateAnalyticsData(): Promise { +async function generateAnalyticsData() { try { console.log("🔄 Fetching analytics data..."); @@ -157,18 +161,29 @@ async function generateAnalyticsData(): Promise { totalRecords: processedData.length, }; - const publicDir = join(process.cwd(), "public"); - if (!existsSync(publicDir)) { - mkdirSync(publicDir, { recursive: true }); - } + console.log("📤 Uploading to Cloudflare R2..."); + + const tempDir = mkdtempSync(join(tmpdir(), "analytics-")); + const tempFilePath = join(tempDir, "analytics-data.json"); - const outputPath = join(publicDir, "analytics-data.json"); - writeFileSync(outputPath, JSON.stringify(analyticsData, null, 2)); + 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( `✅ 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}`); } catch (error) { console.error("❌ Error generating analytics data:", error); diff --git a/apps/web/src/app/(home)/_components/stack-builder.tsx b/apps/web/src/app/(home)/_components/stack-builder.tsx index 143ccc49..c420382f 100644 --- a/apps/web/src/app/(home)/_components/stack-builder.tsx +++ b/apps/web/src/app/(home)/_components/stack-builder.tsx @@ -129,11 +129,15 @@ const getBadgeColors = (category: string): string => { } }; -const TechIcon: React.FC<{ +function TechIcon({ + icon, + name, + className, +}: { icon: string; name: string; className?: string; -}> = ({ icon, name, className }) => { +}) { const [mounted, setMounted] = useState(false); const { theme } = useTheme(); @@ -168,7 +172,7 @@ const TechIcon: React.FC<{ {icon} ); -}; +} const getCategoryDisplayName = (categoryKey: string): string => { const result = categoryKey.replace(/([A-Z])/g, " $1"); @@ -754,10 +758,36 @@ const analyzeStackCompatibility = (stack: StackState): CompatibilityResult => { if ( nextStack.addons.includes("husky") && - !nextStack.addons.includes("biome") + !nextStack.addons.includes("biome") && + !nextStack.addons.includes("oxlint") + ) { + notes.addons.notes.push( + "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( - "Husky addon is selected without Biome. Consider adding Biome for lint-staged integration.", + "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 (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 { if (DEFAULT_STACK.addons.length > 0) { flags.push("--addons none"); @@ -1687,7 +1736,10 @@ const StackBuilder = () => { )} { try { - const response = await fetch("/analytics-data.json"); + const response = await fetch("https://r2.amanv.dev/analytics-data.json"); const analyticsData = await response.json(); setData(analyticsData.data || []); setLastUpdated(analyticsData.lastUpdated || null); 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}`); } catch (error: unknown) { diff --git a/apps/web/src/app/llms-full.txt/route.ts b/apps/web/src/app/llms-full.txt/route.ts new file mode 100644 index 00000000..7fe521d7 --- /dev/null +++ b/apps/web/src/app/llms-full.txt/route.ts @@ -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")); +} diff --git a/apps/web/src/app/llms.mdx/[[...slug]]/route.ts b/apps/web/src/app/llms.mdx/[[...slug]]/route.ts new file mode 100644 index 00000000..80ebdfbd --- /dev/null +++ b/apps/web/src/app/llms.mdx/[[...slug]]/route.ts @@ -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(); +} diff --git a/apps/web/src/lib/constant.ts b/apps/web/src/lib/constant.ts index 563427f2..d68a7f43 100644 --- a/apps/web/src/lib/constant.ts +++ b/apps/web/src/lib/constant.ts @@ -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: [ { id: "trpc", @@ -97,6 +110,7 @@ export const TECH_OPTIONS = { description: "Expo with NativeWind (Tailwind)", icon: "/icon/expo.svg", color: "from-purple-400 to-purple-600", + className: "invert-0 dark:invert", default: false, }, { @@ -105,6 +119,7 @@ export const TECH_OPTIONS = { description: "Expo with Unistyles", icon: "/icon/expo.svg", color: "from-pink-400 to-pink-600", + className: "invert-0 dark:invert", default: false, }, { @@ -368,6 +383,7 @@ export const TECH_OPTIONS = { description: "Default package manager", icon: "/icon/npm.svg", color: "from-red-500 to-red-700", + className: "invert-0 dark:invert", }, { id: "pnpm", @@ -388,8 +404,8 @@ export const TECH_OPTIONS = { addons: [ { id: "pwa", - name: "PWA", - description: "Progressive Web App", + name: "PWA (Progressive Web App)", + description: "Make your app installable and work offline", icon: "", color: "from-blue-500 to-blue-700", default: false, @@ -397,7 +413,7 @@ export const TECH_OPTIONS = { { id: "tauri", name: "Tauri", - description: "Desktop app support", + description: "Build native desktop apps", icon: "/icon/tauri.svg", color: "from-amber-500 to-amber-700", default: false, @@ -405,7 +421,7 @@ export const TECH_OPTIONS = { { id: "starlight", name: "Starlight", - description: "Documentation site with Astro", + description: "Build stellar docs with astro", icon: "/icon/starlight.svg", color: "from-teal-500 to-teal-700", default: false, @@ -413,7 +429,7 @@ export const TECH_OPTIONS = { { id: "biome", name: "Biome", - description: "Linting & formatting", + description: "Format, lint, and more", icon: "/icon/biome.svg", color: "from-green-500 to-green-700", default: false, @@ -421,15 +437,40 @@ export const TECH_OPTIONS = { { id: "husky", name: "Husky", - description: "Git hooks & lint-staged", + description: "Modern native Git hooks made easy", icon: "", color: "from-purple-500 to-purple-700", 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", name: "Turborepo", - description: "Monorepo build system", + description: "High-performance build system", icon: "/icon/turborepo.svg", color: "from-gray-400 to-gray-700", default: true, diff --git a/apps/web/src/lib/get-llm-text.ts b/apps/web/src/lib/get-llm-text.ts new file mode 100644 index 00000000..1a5e231f --- /dev/null +++ b/apps/web/src/lib/get-llm-text.ts @@ -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) { + 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}`; +} diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index 8ec5a55d..8b46c790 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -1,27 +1,19 @@ export type TechCategory = - | "core" - | "frontend" + | "api" + | "webFrontend" + | "nativeFrontend" + | "runtime" | "backend" | "database" - | "auth" | "orm" - | "router"; - -export interface TechNode { - id: string; - type: string; - position: { x: number; y: number }; - data: { - label: string; - category: TechCategory; - description: string; - isDefault: boolean; - alternatives?: string[]; - isActive: boolean; - group?: TechCategory; - isStatic?: boolean; - }; -} + | "dbSetup" + | "webDeploy" + | "auth" + | "packageManager" + | "addons" + | "examples" + | "git" + | "install"; export interface TechEdge { id: string; diff --git a/bun.lock b/bun.lock index 374b8a63..da8c4f8f 100644 --- a/bun.lock +++ b/bun.lock @@ -14,7 +14,7 @@ }, "apps/cli": { "name": "create-better-t-stack", - "version": "2.26.5", + "version": "2.27.1", "bin": { "create-better-t-stack": "dist/index.js", },