diff --git a/.changeset/late-tips-eat.md b/.changeset/late-tips-eat.md new file mode 100644 index 0000000000..197aae6389 --- /dev/null +++ b/.changeset/late-tips-eat.md @@ -0,0 +1,5 @@ +--- +"create-t3-app": minor +--- + +Added support for biome.js as a formatter and linter diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index ae0d0fc562..fb2a397aea 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -55,9 +55,10 @@ jobs: # has to be scaffolded outside the CLI project so that no lint/tsconfig are leaking # through. this way it ensures that it is the app's configs that are being used # FIXME: this is a bit hacky, would rather have --packages=trpc,tailwind,... but not sure how to setup the matrix for that - - run: cd cli && pnpm start ../../ci-${{ matrix.trpc }}-${{ matrix.tailwind }}-${{ matrix.nextAuth }}-${{ matrix.prisma }}-${{ matrix.drizzle}}-${{ matrix.appRouter }}-${{ matrix.dbType }} --noGit --CI --trpc=${{ matrix.trpc }} --tailwind=${{ matrix.tailwind }} --nextAuth=${{ matrix.nextAuth }} --prisma=${{ matrix.prisma }} --drizzle=${{ matrix.drizzle }} --appRouter=${{ matrix.appRouter }} --dbProvider=${{ matrix.dbType }} + - run: cd cli && pnpm start ../../ci-${{ matrix.trpc }}-${{ matrix.tailwind }}-${{ matrix.nextAuth }}-${{ matrix.prisma }}-${{ matrix.drizzle}}-${{ matrix.appRouter }}-${{ matrix.dbType }} --noGit --CI --trpc=${{ matrix.trpc }} --tailwind=${{ matrix.tailwind }} --nextAuth=${{ matrix.nextAuth }} --prisma=${{ matrix.prisma }} --drizzle=${{ matrix.drizzle }} --appRouter=${{ matrix.appRouter }} --dbProvider=${{ matrix.dbType }} --eslint if: ${{ steps.matrix-valid.outputs.continue == 'true' }} # can't use default mysql string cause t3-env blocks that + - run: cd ../ci-${{ matrix.trpc }}-${{ matrix.tailwind }}-${{ matrix.nextAuth }}-${{ matrix.prisma }}-${{ matrix.drizzle}}-${{ matrix.appRouter }}-${{ matrix.dbType }} && pnpm build if: ${{ steps.matrix-valid.outputs.continue == 'true' }} env: diff --git a/cli/src/cli/index.ts b/cli/src/cli/index.ts index ce5ef6ca50..4a0e236a37 100644 --- a/cli/src/cli/index.ts +++ b/cli/src/cli/index.ts @@ -37,6 +37,10 @@ interface CliFlags { appRouter: boolean; /** @internal Used in CI. */ dbProvider: DatabaseProvider; + /** @internal Used in CI */ + eslint: boolean; + /** @internal Used in CI */ + biome: boolean; } interface CliResults { @@ -62,6 +66,8 @@ const defaultOptions: CliResults = { importAlias: "~/", appRouter: false, dbProvider: "sqlite", + eslint: false, + biome: false, }, databaseProvider: "sqlite", }; @@ -145,6 +151,16 @@ export const runCli = async (): Promise => { "Explicitly tell the CLI to use the new Next.js app router", (value) => !!value && value !== "false" ) + .option( + "--eslint [boolean]", + "Experimental: Boolean value if we should install eslint and prettier. Must be used in conjunction with `--CI`.", + (value) => !!value && value !== "false" + ) + .option( + "--biome [boolean]", + "Experimental: Boolean value if we should install biome. Must be used in conjunction with `--CI`.", + (value) => !!value && value !== "false" + ) /** END CI-FLAGS */ .version(getVersion(), "-v, --version", "Display the version number") .addHelpText( @@ -183,6 +199,8 @@ export const runCli = async (): Promise => { if (cliResults.flags.prisma) cliResults.packages.push("prisma"); if (cliResults.flags.drizzle) cliResults.packages.push("drizzle"); if (cliResults.flags.nextAuth) cliResults.packages.push("nextAuth"); + if (cliResults.flags.eslint) cliResults.packages.push("eslint"); + if (cliResults.flags.biome) cliResults.packages.push("biome"); if (cliResults.flags.prisma && cliResults.flags.drizzle) { // We test a matrix of all possible combination of packages in CI. Checking for impossible // combinations here and exiting gracefully is easier than changing the CI matrix to exclude @@ -190,6 +208,10 @@ export const runCli = async (): Promise => { logger.warn("Incompatible combination Prisma + Drizzle. Exiting."); process.exit(0); } + if (cliResults.flags.biome && cliResults.flags.eslint) { + logger.warn("Incompatible combination Biome + ESLint. Exiting."); + process.exit(0); + } if (databaseProviders.includes(cliResults.flags.dbProvider) === false) { logger.warn( `Incompatible database provided. Use: ${databaseProviders.join(", ")}. Exiting.` @@ -300,6 +322,17 @@ export const runCli = async (): Promise => { initialValue: "sqlite", }); }, + linter: () => { + return p.select({ + message: + "Would you like to use ESLint and Prettier or Biome for linting and formatting?", + options: [ + { value: "eslint", label: "ESLint/Prettier" }, + { value: "biome", label: "Biome" }, + ], + initialValue: "eslint", + }); + }, ...(!cliResults.flags.noGit && { git: () => { return p.confirm({ @@ -341,6 +374,8 @@ export const runCli = async (): Promise => { if (project.authentication === "next-auth") packages.push("nextAuth"); if (project.database === "prisma") packages.push("prisma"); if (project.database === "drizzle") packages.push("drizzle"); + if (project.linter === "eslint") packages.push("eslint"); + if (project.linter === "biome") packages.push("biome"); return { appName: project.name ?? cliResults.appName, diff --git a/cli/src/helpers/format.ts b/cli/src/helpers/format.ts new file mode 100644 index 0000000000..d0ed54a0b8 --- /dev/null +++ b/cli/src/helpers/format.ts @@ -0,0 +1,33 @@ +import chalk from "chalk"; +import { execa } from "execa"; +import ora from "ora"; + +import { type PackageManager } from "~/utils/getUserPkgManager.js"; +import { logger } from "~/utils/logger.js"; + +// Runs format and lint command to ensure created repository is tidy upon creation +export const formatProject = async ({ + pkgManager, + projectDir, + eslint, + biome, +}: { + pkgManager: PackageManager; + projectDir: string; + eslint: boolean; + biome: boolean; +}) => { + logger.info("Formatting project..."); + const spinner = ora("Running format command\n").start(); + + if (eslint) { + await execa(pkgManager, ["format:write"], { + cwd: projectDir, + }); + } else if (biome) { + await execa(pkgManager, ["check:unsafe"], { + cwd: projectDir, + }); + } + spinner.succeed(`${chalk.green("Successfully formatted project")}`); +}; diff --git a/cli/src/index.ts b/cli/src/index.ts index 0394f25180..dee8eb0291 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -14,6 +14,7 @@ import { getUserPkgManager } from "~/utils/getUserPkgManager.js"; import { logger } from "~/utils/logger.js"; import { parseNameAndPath } from "~/utils/parseNameAndPath.js"; import { renderTitle } from "~/utils/renderTitle.js"; +import { formatProject } from "./helpers/format.js"; import { installDependencies } from "./helpers/installDependencies.js"; import { getVersion } from "./utils/getT3Version.js"; import { @@ -38,7 +39,7 @@ const main = async () => { const { appName, packages, - flags: { noGit, noInstall, importAlias, appRouter }, + flags: { noGit, noInstall, importAlias, appRouter, eslint, biome }, databaseProvider, } = await runCli(); @@ -85,6 +86,8 @@ const main = async () => { await installDependencies({ projectDir }); } + await formatProject({ pkgManager, projectDir, eslint, biome }); + if (!noGit) { await initializeGit(projectDir); } diff --git a/cli/src/installers/biome.ts b/cli/src/installers/biome.ts new file mode 100644 index 0000000000..ed24047046 --- /dev/null +++ b/cli/src/installers/biome.ts @@ -0,0 +1,30 @@ +import path from "path"; +import fs from "fs-extra"; + +import { PKG_ROOT } from "~/consts.js"; +import { type Installer } from "~/installers/index.js"; +import { addPackageDependency } from "~/utils/addPackageDependency.js"; +import { addPackageScript } from "~/utils/addPackageScript.js"; + +export const biomeInstaller: Installer = ({ projectDir }) => { + addPackageDependency({ + projectDir, + dependencies: ["@biomejs/biome"], + devMode: true, + }); + + const extrasDir = path.join(PKG_ROOT, "template/extras"); + const biomeConfigSrc = path.join(extrasDir, "config/biome.jsonc"); + const biomeConfigDest = path.join(projectDir, "biome.jsonc"); + + fs.copySync(biomeConfigSrc, biomeConfigDest); + + addPackageScript({ + projectDir, + scripts: { + "check:unsafe": "biome check --write --unsafe .", + "check:write": "biome check --write .", + check: "biome check .", + }, + }); +}; diff --git a/cli/src/installers/dependencyVersionMap.ts b/cli/src/installers/dependencyVersionMap.ts index 868214a665..b95d246220 100644 --- a/cli/src/installers/dependencyVersionMap.ts +++ b/cli/src/installers/dependencyVersionMap.ts @@ -16,7 +16,6 @@ export const dependencyVersionMap = { // Drizzle "drizzle-kit": "^0.24.0", "drizzle-orm": "^0.33.0", - "eslint-plugin-drizzle": "^0.2.3", mysql2: "^3.11.0", "@planetscale/database": "^1.19.0", postgres: "^3.4.4", @@ -25,8 +24,6 @@ export const dependencyVersionMap = { // TailwindCSS tailwindcss: "^3.4.3", postcss: "^8.4.39", - prettier: "^3.3.2", - "prettier-plugin-tailwindcss": "^0.6.5", // tRPC "@trpc/client": "^11.0.0-rc.446", @@ -36,5 +33,18 @@ export const dependencyVersionMap = { "@tanstack/react-query": "^5.50.0", superjson: "^2.2.1", "server-only": "^0.0.1", + + // biome + "@biomejs/biome": "1.9.4", + + // eslint / prettier + prettier: "^3.3.2", + "prettier-plugin-tailwindcss": "^0.6.5", + eslint: "^8.57.0", + "eslint-config-next": "^15.0.1", + "eslint-plugin-drizzle": "^0.2.3", + "@types/eslint": "^8.56.10", + "@typescript-eslint/eslint-plugin": "^8.1.0", + "@typescript-eslint/parser": "^8.1.0", } as const; export type AvailableDependencies = keyof typeof dependencyVersionMap; diff --git a/cli/src/installers/drizzle.ts b/cli/src/installers/drizzle.ts index d9c5930b98..c9bbae8590 100644 --- a/cli/src/installers/drizzle.ts +++ b/cli/src/installers/drizzle.ts @@ -1,11 +1,10 @@ import path from "path"; import fs from "fs-extra"; -import { type PackageJson } from "type-fest"; import { PKG_ROOT } from "~/consts.js"; import { type Installer } from "~/installers/index.js"; import { addPackageDependency } from "~/utils/addPackageDependency.js"; -import { type AvailableDependencies } from "./dependencyVersionMap.js"; +import { addPackageScript } from "~/utils/addPackageScript.js"; export const drizzleInstaller: Installer = ({ projectDir, @@ -13,14 +12,9 @@ export const drizzleInstaller: Installer = ({ scopedAppName, databaseProvider, }) => { - const devPackages: AvailableDependencies[] = [ - "drizzle-kit", - "eslint-plugin-drizzle", - ]; - addPackageDependency({ projectDir, - dependencies: devPackages, + dependencies: ["drizzle-kit"], devMode: true, }); addPackageDependency({ @@ -75,24 +69,19 @@ export const drizzleInstaller: Installer = ({ ); const clientDest = path.join(projectDir, "src/server/db/index.ts"); - // add db:* scripts to package.json - const packageJsonPath = path.join(projectDir, "package.json"); - - const packageJsonContent = fs.readJSONSync(packageJsonPath) as PackageJson; - packageJsonContent.scripts = { - ...packageJsonContent.scripts, - "db:push": "drizzle-kit push", - "db:studio": "drizzle-kit studio", - "db:generate": "drizzle-kit generate", - "db:migrate": "drizzle-kit migrate", - }; + addPackageScript({ + projectDir, + scripts: { + "db:push": "drizzle-kit push", + "db:studio": "drizzle-kit studio", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", + }, + }); fs.copySync(configFile, configDest); fs.mkdirSync(path.dirname(schemaDest), { recursive: true }); fs.writeFileSync(schemaDest, schemaContent); fs.writeFileSync(configDest, configContent); fs.copySync(clientSrc, clientDest); - fs.writeJSONSync(packageJsonPath, packageJsonContent, { - spaces: 2, - }); }; diff --git a/cli/src/installers/eslint.ts b/cli/src/installers/eslint.ts index 6600168271..4b0a872fd7 100644 --- a/cli/src/installers/eslint.ts +++ b/cli/src/installers/eslint.ts @@ -2,11 +2,61 @@ import path from "path"; import fs from "fs-extra"; import { _initialConfig } from "~/../template/extras/config/_eslint.js"; +import { PKG_ROOT } from "~/consts.js"; import { type Installer } from "~/installers/index.js"; +import { addPackageDependency } from "~/utils/addPackageDependency.js"; +import { addPackageScript } from "~/utils/addPackageScript.js"; +import { type AvailableDependencies } from "./dependencyVersionMap.js"; +// Also installs prettier export const dynamicEslintInstaller: Installer = ({ projectDir, packages }) => { - const usingDrizzle = !!packages?.drizzle?.inUse; + const devPackages: AvailableDependencies[] = [ + "prettier", + "eslint", + "eslint-config-next", + "@types/eslint", + "@typescript-eslint/eslint-plugin", + "@typescript-eslint/parser", + ]; + + if (packages?.tailwind.inUse) { + devPackages.push("prettier-plugin-tailwindcss"); + } + if (packages?.drizzle.inUse) { + devPackages.push("eslint-plugin-drizzle"); + } + + addPackageDependency({ + projectDir, + dependencies: devPackages, + devMode: true, + }); + const extrasDir = path.join(PKG_ROOT, "template/extras"); + // Prettier + let prettierSrc: string; + if (packages?.tailwind.inUse) { + prettierSrc = path.join(extrasDir, "config/_tailwind.prettier.config.js"); + } else { + prettierSrc = path.join(extrasDir, "config/_prettier.config.js"); + } + const prettierDest = path.join(projectDir, "prettier.config.js"); + + fs.copySync(prettierSrc, prettierDest); + + addPackageScript({ + projectDir, + scripts: { + lint: "next lint", + "lint:fix": "next lint --fix", + check: "next lint && tsc --noEmit", + "format:write": 'prettier --write "**/*.{ts,tsx,js,jsx,mdx}" --cache', + "format:check": 'prettier --check "**/*.{ts,tsx,js,jsx,mdx}" --cache', + }, + }); + + // eslint + const usingDrizzle = !!packages?.drizzle?.inUse; const eslintConfig = getEslintConfig({ usingDrizzle }); // Convert config from _eslint.config.json to .eslintrc.cjs diff --git a/cli/src/installers/index.ts b/cli/src/installers/index.ts index 776a16ba1c..366760a23b 100644 --- a/cli/src/installers/index.ts +++ b/cli/src/installers/index.ts @@ -4,6 +4,7 @@ import { prismaInstaller } from "~/installers/prisma.js"; import { tailwindInstaller } from "~/installers/tailwind.js"; import { trpcInstaller } from "~/installers/trpc.js"; import { type PackageManager } from "~/utils/getUserPkgManager.js"; +import { biomeInstaller } from "./biome.js"; import { dbContainerInstaller } from "./dbContainer.js"; import { drizzleInstaller } from "./drizzle.js"; import { dynamicEslintInstaller } from "./eslint.js"; @@ -18,6 +19,7 @@ export const availablePackages = [ "trpc", "envVariables", "eslint", + "biome", "dbContainer", ] as const; export type AvailablePackages = (typeof availablePackages)[number]; @@ -83,7 +85,11 @@ export const buildPkgInstallerMap = ( installer: envVariablesInstaller, }, eslint: { - inUse: true, + inUse: packages.includes("eslint"), installer: dynamicEslintInstaller, }, + biome: { + inUse: packages.includes("biome"), + installer: biomeInstaller, + }, }); diff --git a/cli/src/installers/prisma.ts b/cli/src/installers/prisma.ts index f92da12395..36c2b7cff7 100644 --- a/cli/src/installers/prisma.ts +++ b/cli/src/installers/prisma.ts @@ -1,10 +1,10 @@ import path from "path"; import fs from "fs-extra"; -import { type PackageJson } from "type-fest"; import { PKG_ROOT } from "~/consts.js"; import { type Installer } from "~/installers/index.js"; import { addPackageDependency } from "~/utils/addPackageDependency.js"; +import { addPackageScript } from "~/utils/addPackageScript.js"; export const prismaInstaller: Installer = ({ projectDir, @@ -65,21 +65,16 @@ export const prismaInstaller: Installer = ({ ); const clientDest = path.join(projectDir, "src/server/db.ts"); - // add postinstall and push script to package.json - const packageJsonPath = path.join(projectDir, "package.json"); - - const packageJsonContent = fs.readJSONSync(packageJsonPath) as PackageJson; - packageJsonContent.scripts = { - ...packageJsonContent.scripts, - postinstall: "prisma generate", - "db:push": "prisma db push", - "db:studio": "prisma studio", - "db:generate": "prisma migrate dev", - "db:migrate": "prisma migrate deploy", - }; + addPackageScript({ + projectDir, + scripts: { + postinstall: "prisma generate", + "db:push": "prisma db push", + "db:studio": "prisma studio", + "db:generate": "prisma migrate dev", + "db:migrate": "prisma migrate deploy", + }, + }); fs.copySync(clientSrc, clientDest); - fs.writeJSONSync(packageJsonPath, packageJsonContent, { - spaces: 2, - }); }; diff --git a/cli/src/installers/tailwind.ts b/cli/src/installers/tailwind.ts index 86188a241d..3df90bfa66 100644 --- a/cli/src/installers/tailwind.ts +++ b/cli/src/installers/tailwind.ts @@ -1,6 +1,5 @@ import path from "path"; import fs from "fs-extra"; -import { type PackageJson } from "type-fest"; import { PKG_ROOT } from "~/consts.js"; import { type Installer } from "~/installers/index.js"; @@ -9,12 +8,7 @@ import { addPackageDependency } from "~/utils/addPackageDependency.js"; export const tailwindInstaller: Installer = ({ projectDir }) => { addPackageDependency({ projectDir, - dependencies: [ - "tailwindcss", - "postcss", - "prettier", - "prettier-plugin-tailwindcss", - ], + dependencies: ["tailwindcss", "postcss"], devMode: true, }); @@ -26,27 +20,10 @@ export const tailwindInstaller: Installer = ({ projectDir }) => { const postcssCfgSrc = path.join(extrasDir, "config/postcss.config.js"); const postcssCfgDest = path.join(projectDir, "postcss.config.js"); - const prettierSrc = path.join(extrasDir, "config/_prettier.config.js"); - const prettierDest = path.join(projectDir, "prettier.config.js"); - const cssSrc = path.join(extrasDir, "src/styles/globals.css"); const cssDest = path.join(projectDir, "src/styles/globals.css"); - // add format:* scripts to package.json - const packageJsonPath = path.join(projectDir, "package.json"); - - const packageJsonContent = fs.readJSONSync(packageJsonPath) as PackageJson; - packageJsonContent.scripts = { - ...packageJsonContent.scripts, - "format:write": 'prettier --write "**/*.{ts,tsx,js,jsx,mdx}" --cache', - "format:check": 'prettier --check "**/*.{ts,tsx,js,jsx,mdx}" --cache', - }; - fs.copySync(twCfgSrc, twCfgDest); fs.copySync(postcssCfgSrc, postcssCfgDest); fs.copySync(cssSrc, cssDest); - fs.copySync(prettierSrc, prettierDest); - fs.writeJSONSync(packageJsonPath, packageJsonContent, { - spaces: 2, - }); }; diff --git a/cli/src/utils/addPackageScript.ts b/cli/src/utils/addPackageScript.ts new file mode 100644 index 0000000000..fc14a7d2df --- /dev/null +++ b/cli/src/utils/addPackageScript.ts @@ -0,0 +1,25 @@ +import path from "path"; +import fs from "fs-extra"; +import sortPackageJson from "sort-package-json"; +import { type PackageJson } from "type-fest"; + +export const addPackageScript = (opts: { + scripts: Record; + projectDir: string; +}) => { + const { scripts, projectDir } = opts; + + const packageJsonPath = path.join(projectDir, "package.json"); + const packageJsonContent = fs.readJSONSync(packageJsonPath) as PackageJson; + + packageJsonContent.scripts = { + ...packageJsonContent.scripts, + ...scripts, + }; + + const sortedPkgJson = sortPackageJson(packageJsonContent); + + fs.writeJSONSync(packageJsonPath, sortedPkgJson, { + spaces: 2, + }); +}; diff --git a/cli/template/base/package.json b/cli/template/base/package.json index 00ca211bdc..5ba41fb202 100644 --- a/cli/template/base/package.json +++ b/cli/template/base/package.json @@ -7,9 +7,6 @@ "dev": "next dev --turbo", "build": "next build", "start": "next start", - "lint": "next lint", - "lint:fix": "next lint --fix", - "check": "next lint && tsc --noEmit", "preview": "next build && next start", "typecheck": "tsc --noEmit" }, @@ -22,14 +19,9 @@ "zod": "^3.23.3" }, "devDependencies": { - "@types/eslint": "^8.56.10", "@types/node": "^20.14.10", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", - "@typescript-eslint/eslint-plugin": "^8.1.0", - "@typescript-eslint/parser": "^8.1.0", - "eslint": "^8.57.0", - "eslint-config-next": "^15.0.1", "typescript": "^5.5.3" } } diff --git a/cli/template/extras/config/_prettier.config.js b/cli/template/extras/config/_prettier.config.js index da332bd898..6f1a202fbd 100644 --- a/cli/template/extras/config/_prettier.config.js +++ b/cli/template/extras/config/_prettier.config.js @@ -1,4 +1,2 @@ -/** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */ -export default { - plugins: ["prettier-plugin-tailwindcss"], -}; +/** @type {import('prettier').Config} */ +export default {}; diff --git a/cli/template/extras/config/_tailwind.prettier.config.js b/cli/template/extras/config/_tailwind.prettier.config.js new file mode 100644 index 0000000000..da332bd898 --- /dev/null +++ b/cli/template/extras/config/_tailwind.prettier.config.js @@ -0,0 +1,4 @@ +/** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */ +export default { + plugins: ["prettier-plugin-tailwindcss"], +}; diff --git a/cli/template/extras/config/biome.jsonc b/cli/template/extras/config/biome.jsonc new file mode 100644 index 0000000000..6c718cf8fd --- /dev/null +++ b/cli/template/extras/config/biome.jsonc @@ -0,0 +1,25 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true, + }, + "files": { "ignoreUnknown": false, "ignore": [] }, + "formatter": { "enabled": true }, + "organizeImports": { "enabled": true }, + "linter": { + "enabled": true, + "rules": { + "nursery": { + "useSortedClasses": { + "level": "error", + "options": { + "functions": ["clsx", "cva", "cn"], + }, + }, + }, + "recommended": true, + }, + }, +} diff --git a/cli/template/extras/src/trpc/react.tsx b/cli/template/extras/src/trpc/react.tsx index 8c0521a74e..f84b4da390 100644 --- a/cli/template/extras/src/trpc/react.tsx +++ b/cli/template/extras/src/trpc/react.tsx @@ -17,7 +17,10 @@ const getQueryClient = () => { return createQueryClient(); } // Browser: use singleton pattern to keep the same query client - return (clientQueryClientSingleton ??= createQueryClient()); + if (!clientQueryClientSingleton) { + clientQueryClientSingleton = createQueryClient(); + } + return clientQueryClientSingleton; }; export const api = createTRPCReact();