diff --git a/.agents/index.md b/.agents/index.md index 68795b27..24bc0cdd 100644 --- a/.agents/index.md +++ b/.agents/index.md @@ -49,6 +49,7 @@ node ../cli/packages/cli/dist/index.js create my-app | Add-on | Plugin that extends apps (auth, DB, etc) | `--add-ons` | | Template | Reusable project template | `--template` | | Framework | React or Solid | `--framework` | +| Bundler | Vite (default) or Rsbuild | `--bundler` | ## CLI Commands @@ -70,6 +71,7 @@ node ../cli/packages/cli/dist/index.js create my-app | -------------------- | ------------------------------------------------------- | | `--add-ons ` | Comma-separated add-on IDs | | `--framework ` | React or Solid | +| `--bundler ` | Vite (default) or Rsbuild | | `--toolchain ` | Toolchain (use `--list-add-ons` to see options) | | `--deployment ` | Deployment target (use `--list-add-ons` to see options) | | `--template ` | Use template URL/path or built-in ID | @@ -86,6 +88,7 @@ node ../cli/packages/cli/dist/index.js create my-app | `projectName` | string | Project name | | `typescript` | boolean | Always true (TanStack Start requires TS) | | `tailwind` | boolean | False for blank unless an add-on requires it | +| `bundler` | string | Resolved bundler (`vite` or `rsbuild`) | | `blank` | boolean | True when using the blank project preset | | `fileRouter` | boolean | Always true | | `addOnEnabled` | object | `{ [id]: boolean }` | @@ -131,7 +134,7 @@ This project uses [TanStack Intent](https://github.com/TanStack/intent). Run `np # Skill mappings — when working in these areas, load the linked skill file into context. skills: - - task: "scaffold a new TanStack app with create command, framework, template, toolchain, deployment, or add-on flags" + - task: "scaffold a new TanStack app with create command, framework, bundler, template, toolchain, deployment, or add-on flags" load: "packages/cli/skills/create-app-scaffold/SKILL.md" - task: "add add-ons to an existing project using tanstack add" load: "packages/cli/skills/add-addons-existing-app/SKILL.md" diff --git a/.changeset/green-builders-rise.md b/.changeset/green-builders-rise.md new file mode 100644 index 00000000..dd5ab371 --- /dev/null +++ b/.changeset/green-builders-rise.md @@ -0,0 +1,7 @@ +--- +'@tanstack/create': minor +'@tanstack/cli': minor +--- + +Add first-class Rsbuild bundler support for React and Solid TanStack Start and +router-only scaffolds, including Tailwind and ESLint/Biome toolchains. diff --git a/README.md b/README.md index 6f7e84ad..edda9d76 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ npx @tanstack/cli create my-app - **TanStack Start** - Full-stack SSR framework (default) - **TanStack Router** - Type-safe routing (`--router-only` for SPA) +- **Vite or Rsbuild** - Choose the bundler for React and Solid projects - **Add-ons** - Auth, database, deployment, monitoring, and more - **CLI Introspection** - Agent-friendly discovery via JSON CLI output @@ -59,6 +60,9 @@ npx @tanstack/cli create my-app --blank -y # Create Router-only SPA (no SSR) npx @tanstack/cli create my-app --router-only +# Create with Rsbuild +npx @tanstack/cli create my-app --bundler rsbuild -y + # With add-ons npx @tanstack/cli create my-app --add-ons clerk,drizzle,tanstack-query @@ -84,7 +88,7 @@ npx @tanstack/cli search-docs "loaders" --library router --framework react --jso TanStack CLI sends anonymous usage telemetry by default. -- Sent: command usage, durations, selected framework/package manager/add-on ids, and coarse result metadata +- Sent: command usage, durations, selected framework/bundler/package manager/add-on ids, and coarse result metadata - Never sent: project names, file paths, target directories, raw search queries, raw template URLs/paths, add-on config values, env vars, or raw error messages - Disabled automatically in `CI`, or when `DO_NOT_TRACK=1` or `TANSTACK_CLI_TELEMETRY_DISABLED=1` diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 259ae629..73b912cc 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -18,6 +18,7 @@ tanstack create [project-name] [options] | `--blank` | Create a minimal one-route Start project without default starter UI, examples, Tailwind, devtools, or a test stack | | `--package-manager ` | `npm`, `pnpm`, `yarn`, `bun`, `deno` | | `--framework ` | `React`, `Solid` | +| `--bundler ` | `vite` (default), `rsbuild` | | `--router-only` | Create file-based Router-only app without TanStack Start (add-ons/deployment/template disabled) | | `--toolchain ` | Toolchain add-on (use `--list-add-ons` to see options) | | `--deployment ` | Deployment add-on (use `--list-add-ons` to see options) | @@ -41,6 +42,8 @@ tanstack create my-app --blank -y tanstack create my-app --blank --deployment cloudflare -y tanstack create my-app --add-ons clerk,drizzle,tanstack-query tanstack create my-app --router-only --toolchain eslint --no-examples +tanstack create my-app --bundler rsbuild -y +tanstack create my-app --framework Solid --bundler rsbuild --router-only --blank --toolchain biome -y tanstack create my-app --template https://example.com/template.json tanstack create my-app --template ecommerce tanstack create --list-add-ons --framework React --json @@ -126,6 +129,7 @@ await create.createApp(environment, { targetDir: '/app', framework: framework!, mode: 'file-router', + bundler: 'vite', typescript: true, tailwind: true, packageManager: 'pnpm', diff --git a/docs/quick-start.md b/docs/quick-start.md index f49fb0fd..25834e47 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -28,6 +28,12 @@ npx @tanstack/cli create my-app --add-ons tanstack-query,clerk,drizzle -y # Router-only SPA (no SSR) npx @tanstack/cli create my-app --router-only -y + +# Rsbuild instead of the default Vite bundler +npx @tanstack/cli create my-app --bundler rsbuild -y + +# Minimal router-only Rsbuild app +npx @tanstack/cli create my-app --bundler rsbuild --router-only --blank -y ``` Blank projects omit the default starter interface, examples, Tailwind, diff --git a/packages/cli/skills/create-app-scaffold/SKILL.md b/packages/cli/skills/create-app-scaffold/SKILL.md index a0162ce9..bc1bc24e 100644 --- a/packages/cli/skills/create-app-scaffold/SKILL.md +++ b/packages/cli/skills/create-app-scaffold/SKILL.md @@ -1,14 +1,14 @@ --- name: create-app-scaffold description: > - Scaffold a TanStack app with tanstack create using --framework, --template, + Scaffold a TanStack app with tanstack create using --framework, --bundler, --template, --toolchain, --deployment, --add-ons, --blank, and --router-only. Covers flag compatibility, non-interactive defaults, and intent-preserving command construction. metadata: type: core library: tanstack-cli - library_version: "0.70.0" + library_version: "0.71.0" --- # Create App Scaffold @@ -28,6 +28,19 @@ npx @tanstack/cli create acme-web \ ## Core Patterns +### Choose Vite or Rsbuild explicitly + +Vite is the default. Use Rsbuild for React or Solid when the requested project +fits its initial compatibility surface. + +```bash +npx @tanstack/cli create acme-web \ + --framework react \ + --bundler rsbuild \ + --toolchain biome \ + -y +``` + ### Build a deterministic non-interactive scaffold ```bash @@ -162,5 +175,6 @@ See also: query-docs-library-metadata/SKILL.md § Common Mistakes - [Create flag compatibility matrix](references/create-flag-compatibility-matrix.md) - [Framework adapter options](references/framework-adapters.md) +- [Bundler options](references/bundlers.md) - [Deployment provider options](references/deployment-providers.md) - [Toolchain options](references/toolchains.md) diff --git a/packages/cli/skills/create-app-scaffold/references/bundlers.md b/packages/cli/skills/create-app-scaffold/references/bundlers.md new file mode 100644 index 00000000..32c23a88 --- /dev/null +++ b/packages/cli/skills/create-app-scaffold/references/bundlers.md @@ -0,0 +1,27 @@ +# Bundler Options + +Targets `@tanstack/cli` v0.71.0. + +## Supported values + +- `vite` (default) +- `rsbuild` + +## Usage + +```bash +npx @tanstack/cli create app --framework react --bundler vite -y +npx @tanstack/cli create app --framework solid --bundler rsbuild -y +``` + +Rsbuild supports full TanStack Start and router-only projects, including the +standard and `--blank` presets, built-in example pages, Tailwind, ESLint, and +Biome. It does not currently support templates, deployment adapters, or +catalog business/example add-ons. `tanstack add` is limited to ESLint/Biome in +Rsbuild projects. + +Use the bundler-aware catalog view before constructing an Rsbuild command: + +```bash +npx @tanstack/cli create --list-add-ons --bundler rsbuild --json +``` diff --git a/packages/cli/skills/create-app-scaffold/references/create-flag-compatibility-matrix.md b/packages/cli/skills/create-app-scaffold/references/create-flag-compatibility-matrix.md index 2b68efd0..dbe8fffe 100644 --- a/packages/cli/skills/create-app-scaffold/references/create-flag-compatibility-matrix.md +++ b/packages/cli/skills/create-app-scaffold/references/create-flag-compatibility-matrix.md @@ -1,12 +1,13 @@ # Create Flag Compatibility Matrix -Targets `@tanstack/cli` v0.70.0. +Targets `@tanstack/cli` v0.71.0. ## Compatibility | Flag | Works with normal create | Works with `--router-only` | Notes | |---|---|---|---| | `--framework` | yes | yes | Framework is still honored in both modes. | +| `--bundler` | yes | yes | `vite` is the default; `rsbuild` supports React and Solid. | | `--toolchain` | yes | yes | Toolchain selection remains available. | | `--add-ons` | yes | no | Ignored in router-only mode. | | `--deployment` | yes | no | Ignored in router-only mode. | @@ -15,14 +16,18 @@ Targets `@tanstack/cli` v0.70.0. | `--blank` | yes | yes | Omits starter UI, examples, Tailwind, devtools, and test tooling unless an explicit integration requires them. Cannot be combined with a template, `--examples`, or `--tailwind`. | | `--tailwind` / `--no-tailwind` | deprecated/ignored | deprecated/ignored | Standard scaffolds enable Tailwind; use `--blank` for the supported minimal preset. | +With `--bundler rsbuild`, only toolchain catalog entries are compatible. +Templates, deployments, and business/example add-ons are rejected. Built-in +demo pages controlled by `--examples` remain available. + Source: `packages/cli/src/command-line.ts:337` ## Recommended command construction order -1. Choose mode (`--router-only` or full scaffold). -2. If full scaffold, resolve add-ons and deployment first. -3. Add framework and toolchain. -4. Pass explicit add-on ids and use `-y` only after flags are final. +1. Choose framework and bundler. +2. Choose mode (`--router-only` or full scaffold). +3. If using Vite full scaffold, resolve add-ons, template, and deployment. +4. Add the toolchain and use `-y` only after flags are final. ## Safe presets @@ -35,4 +40,7 @@ npx @tanstack/cli create app --blank --framework react --deployment cloudflare - # Router-only preset npx @tanstack/cli create app --router-only --framework react --toolchain biome -y + +# Rsbuild preset +npx @tanstack/cli create app --framework solid --bundler rsbuild --toolchain biome -y ``` diff --git a/packages/cli/skills/create-app-scaffold/references/framework-adapters.md b/packages/cli/skills/create-app-scaffold/references/framework-adapters.md index f932bf95..e57ec973 100644 --- a/packages/cli/skills/create-app-scaffold/references/framework-adapters.md +++ b/packages/cli/skills/create-app-scaffold/references/framework-adapters.md @@ -1,6 +1,6 @@ # Framework Adapter Options -Targets `@tanstack/cli` v0.70.0. +Targets `@tanstack/cli` v0.71.0. ## Supported values diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index ecd8bb4e..804455fb 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -13,11 +13,13 @@ import { createApp, devAddOn, getAllAddOns, + getBundlers, getFrameworkByName, getFrameworks, initAddOn, initStarter, isDemoFilePath, + resolveBundler, } from '@tanstack/create' import { LIBRARY_GROUPS, @@ -167,7 +169,7 @@ function getCreateCommandVariant(options: CliOptions) { return 'scaffold' } -function getCreateTelemetryProperties(projectName: string, options: CliOptions) { +export function getCreateTelemetryProperties(projectName: string, options: CliOptions) { const addOnIds = Array.isArray(options.addOns) ? sanitizeIdList(options.addOns) : undefined @@ -181,6 +183,7 @@ function getCreateTelemetryProperties(projectName: string, options: CliOptions) : undefined, command_variant: getCreateCommandVariant(options), deployment: options.deployment ? sanitizeId(options.deployment) : undefined, + bundler: options.bundler ? sanitizeId(options.bundler) : undefined, examples: options.examples, blank: !!options.blank, framework: options.framework ? sanitizeId(options.framework) : undefined, @@ -200,7 +203,7 @@ function getCreateTelemetryProperties(projectName: string, options: CliOptions) } } -function getResolvedCreateTelemetryProperties( +export function getResolvedCreateTelemetryProperties( finalOptions: Options, cliOptions: CliOptions, ) { @@ -221,6 +224,7 @@ function getResolvedCreateTelemetryProperties( add_on_count: addOnIds.length, add_on_ids: addOnIds, deployment: deployment ? sanitizeId(deployment.id) : undefined, + bundler: sanitizeId(finalOptions.bundler ?? 'vite'), examples: includeExamples, blank: finalOptions.projectPreset === 'blank', framework: sanitizeId(finalOptions.framework.id), @@ -306,6 +310,9 @@ export function cli({ lines.push(` Project: ${finalOptions.projectName}`) lines.push(` Location: ${finalOptions.targetDir}`) lines.push(` Framework: ${finalOptions.framework.name}`) + lines.push( + ` Bundler: ${resolveBundler(finalOptions.framework, finalOptions.bundler).name}`, + ) lines.push(` Mode: ${finalOptions.mode}`) if (finalOptions.projectPreset === 'blank') { lines.push(` Preset: blank`) @@ -565,6 +572,13 @@ export function cli({ } } + const bundlers = new Set() + for (const framework of getFrameworks()) { + for (const bundler of getBundlers(framework)) { + bundlers.add(bundler.id) + } + } + // Mode is always file-router (TanStack Start) const defaultMode = 'file-router' const categoryAliases: Record = { @@ -660,6 +674,7 @@ export function cli({ const addOns = await getAllAddOns( getFrameworkByName(options.framework || defaultFramework || 'React')!, defaultMode, + options.bundler, ) const visibleAddOns = addOns.filter((a) => !forcedAddOns.includes(a.id)) telemetry.mergeProperties({ @@ -705,6 +720,7 @@ export function cli({ const addOns = await getAllAddOns( getFrameworkByName(options.framework || defaultFramework || 'React')!, defaultMode, + options.bundler, ) const addOn = addOns.find((a) => a.id === options.addonDetails) ?? @@ -958,6 +974,23 @@ export function cli({ } cmd + .option( + `--bundler <${Array.from(bundlers).join('|')}>`, + 'select the project bundler', + (value) => { + const bundler = Array.from(bundlers).find( + (item) => item.toLowerCase() === value.toLowerCase(), + ) + if (!bundler) { + throw new InvalidArgumentError( + `Invalid bundler: ${value}. The following are allowed: ${Array.from( + bundlers, + ).join(', ')}`, + ) + } + return bundler + }, + ) .option( '--starter [url-or-id]', 'DEPRECATED: use --template. Initializes from a template URL or built-in id', diff --git a/packages/cli/src/command-line.ts b/packages/cli/src/command-line.ts index 7d56d9e7..71e72710 100644 --- a/packages/cli/src/command-line.ts +++ b/packages/cli/src/command-line.ts @@ -9,6 +9,7 @@ import { getRawRegistry, loadStarter, populateAddOnOptionsDefaults, + resolveBundler, } from '@tanstack/create' import { @@ -320,6 +321,36 @@ export function validateLegacyCreateFlags(cliOptions: CliOptions): { } { const warnings: Array = [] const legacyTemplate = getLegacyTemplateValue(cliOptions.template) + const isSupportedLegacyTemplate = + legacyTemplate !== undefined && + SUPPORTED_LEGACY_TEMPLATES.has(legacyTemplate) + + if (cliOptions.bundler?.toLowerCase() === 'rsbuild') { + if ( + cliOptions.starter || + (cliOptions.template && !isSupportedLegacyTemplate) || + cliOptions.templateId + ) { + return { + warnings, + error: + 'Rsbuild does not currently support --starter, --template, or --template-id.', + } + } + if (cliOptions.deployment) { + return { + warnings, + error: 'Rsbuild does not currently support --deployment.', + } + } + if (cliOptions.addOns) { + return { + warnings, + error: + 'Rsbuild currently supports toolchains only. Use --toolchain eslint or --toolchain biome.', + } + } + } if ( cliOptions.blank && @@ -456,6 +487,9 @@ export async function normalizeOptions( const blank = cliOptions.blank === true const legacyTemplate = getLegacyTemplateValue(cliOptions.template) + const isSupportedLegacyTemplate = + legacyTemplate !== undefined && + SUPPORTED_LEGACY_TEMPLATES.has(legacyTemplate) if (!cliOptions.starter) { if (cliOptions.template && !legacyTemplate) { @@ -476,6 +510,30 @@ export async function normalizeOptions( const preferredFramework = (cliOptions.framework || 'react').toLowerCase() + const initialFramework = getFrameworkById(preferredFramework) + let framework = initialFramework! + const bundler = initialFramework + ? resolveBundler(initialFramework, cliOptions.bundler).id + : (cliOptions.bundler ?? 'vite') + + if (bundler === 'rsbuild') { + if ( + cliOptions.starter || + (cliOptions.template && !isSupportedLegacyTemplate) || + cliOptions.templateId + ) { + throw new Error('Rsbuild does not currently support templates.') + } + if (cliOptions.deployment || opts?.forcedDeployment) { + throw new Error('Rsbuild does not currently support deployments.') + } + if (forcedAddOns?.length || cliOptions.addOns) { + throw new Error( + 'Rsbuild currently supports toolchains only. Use --toolchain eslint or --toolchain biome.', + ) + } + } + const starter = !routerOnly && !blank && cliOptions.starter ? await loadStarter( await resolveStarterSpecifier(cliOptions.starter, preferredFramework), @@ -490,7 +548,11 @@ export async function normalizeOptions( mode = starter.mode } - const framework = getFrameworkById(cliOptions.framework || 'react')! + const resolvedFramework = getFrameworkById(cliOptions.framework || 'react') + if (resolvedFramework) { + resolveBundler(resolvedFramework, bundler) + } + framework = resolvedFramework! async function selectAddOns() { // Edge case for Windows Powershell @@ -531,7 +593,7 @@ export async function normalizeOptions( selectedAddOns.add(opts.forcedDeployment) } - return await finalizeAddOns(framework, mode, Array.from(selectedAddOns)) + return await finalizeAddOns(framework, mode, Array.from(selectedAddOns), bundler) } return [] @@ -561,6 +623,7 @@ export async function normalizeOptions( targetDir, framework, mode, + bundler, typescript, tailwind, packageManager: @@ -577,6 +640,7 @@ export async function normalizeOptions( }, starter: starter, projectPreset: blank ? 'blank' : 'default', + routerOnly, } ;(normalized as Options & { includeExamples?: boolean }).includeExamples = diff --git a/packages/cli/src/dev-watch.ts b/packages/cli/src/dev-watch.ts index 92062892..b5c15b8c 100644 --- a/packages/cli/src/dev-watch.ts +++ b/packages/cli/src/dev-watch.ts @@ -257,6 +257,7 @@ export class DevWatchManager { registeredFramework, this.options.cliOptions.mode, chosenAddonIds, + this.options.cliOptions.bundler, ) // Check if package metadata was modified @@ -467,6 +468,8 @@ export class DevWatchManager { addOns, basePackageJSON, optionalPackages, + bundlers: this.options.framework.bundlers, + defaultBundler: this.options.framework.defaultBundler, supportedModes: this.options.framework.supportedModes, } } diff --git a/packages/cli/src/options.ts b/packages/cli/src/options.ts index f09b55a2..0706ef11 100644 --- a/packages/cli/src/options.ts +++ b/packages/cli/src/options.ts @@ -2,12 +2,14 @@ import { intro } from '@clack/prompts' import { finalizeAddOns, + getAllAddOns, getFrameworkById, getFrameworks, getPackageManager, loadStarter, populateAddOnOptionsDefaults, readConfigFile, + resolveBundler, } from '@tanstack/create' import { @@ -15,6 +17,7 @@ import { promptForAddOnOptions, promptForEnvVars, selectAddOns, + selectBundler, selectDeployment, selectExamples, selectFramework, @@ -68,6 +71,17 @@ export async function promptForCreateOptions( } } + options.bundler = await selectBundler(options.framework, cliOptions.bundler) + + if (options.bundler === 'rsbuild') { + if (forcedAddOns.length > 0) { + throw new Error('Rsbuild does not currently support forced add-ons.') + } + if (forcedDeployment) { + throw new Error('Rsbuild does not currently support deployments.') + } + } + const projectLocation = resolveProjectLocation({ projectName: cliOptions.projectName ?? (await getProjectName()), targetDir: cliOptions.targetDir, @@ -107,7 +121,7 @@ export async function promptForCreateOptions( } } - if (!routerOnly && !blank && !cliOptions.starter) { + if (options.bundler !== 'rsbuild' && !routerOnly && !blank && !cliOptions.starter) { const starterChoices = await listTemplateChoices(options.framework.id) const selectedTemplateId = await selectTemplate( starterChoices.map((choice) => ({ @@ -121,7 +135,7 @@ export async function promptForCreateOptions( } } - const starter = !routerOnly && !blank && cliOptions.starter + const starter = options.bundler !== 'rsbuild' && !routerOnly && !blank && cliOptions.starter ? await loadStarter( await resolveStarterSpecifier(cliOptions.starter, options.framework.id), ) @@ -146,13 +160,13 @@ export async function promptForCreateOptions( // Toolchain selection const toolchain = - blank && cliOptions.toolchain === undefined + blank && options.bundler !== 'rsbuild' && cliOptions.toolchain === undefined ? undefined : await selectToolchain(options.framework, cliOptions.toolchain) // Deployment selection let deployment: string | undefined - if (routerOnly) { + if (routerOnly || options.bundler === 'rsbuild') { deployment = undefined } else if (cliOptions.deployment) { deployment = cliOptions.deployment @@ -185,7 +199,7 @@ export async function promptForCreateOptions( addOns.add(deployment) } - if (!routerOnly) { + if (!routerOnly && options.bundler !== 'rsbuild') { for (const addOn of starter?.dependsOn || []) { addOns.add(addOn) } @@ -194,14 +208,14 @@ export async function promptForCreateOptions( } } - if (!routerOnly && Array.isArray(cliOptions.addOns)) { + if (!routerOnly && options.bundler !== 'rsbuild' && Array.isArray(cliOptions.addOns)) { for (const addOn of cliOptions.addOns) { if (addOn.toLowerCase() === 'start') { continue } addOns.add(addOn) } - } else if (!routerOnly && !blank) { + } else if (!routerOnly && options.bundler !== 'rsbuild' && !blank) { for (const addOn of await selectAddOns( options.framework, options.mode, @@ -227,7 +241,7 @@ export async function promptForCreateOptions( } const chosenAddOns = Array.from( - await finalizeAddOns(options.framework, options.mode, Array.from(addOns)), + await finalizeAddOns(options.framework, options.mode, Array.from(addOns), options.bundler), ) options.chosenAddOns = includeExamples ? chosenAddOns @@ -236,6 +250,7 @@ export async function promptForCreateOptions( options.tailwind = !blank || options.chosenAddOns.some((addOn) => addOn.tailwind === true) options.projectPreset = blank ? 'blank' : 'default' + options.routerOnly = routerOnly // Prompt for add-on options in interactive mode if (Array.isArray(cliOptions.addOns)) { @@ -289,6 +304,48 @@ export async function promptForAddOns(): Promise> { const addOns: Set = new Set() + const bundler = resolveBundler(framework, config.bundler).id + + if (bundler === 'rsbuild') { + const toolchains = getAllAddOns(framework, config.mode!, bundler).filter( + (addOn) => addOn.type === 'toolchain', + ) + const configuredAddOnIds = new Set( + config.chosenAddOns.map((addOn) => addOn.toLowerCase()), + ) + const configuredExclusiveGroups = new Set( + toolchains + .filter((addOn) => configuredAddOnIds.has(addOn.id.toLowerCase())) + .flatMap((addOn) => addOn.exclusive ?? []), + ) + const unavailableToolchains = toolchains + .filter( + (addOn) => + configuredAddOnIds.has(addOn.id.toLowerCase()) || + addOn.exclusive?.some((exclusive) => + configuredExclusiveGroups.has(exclusive), + ), + ) + .map((addOn) => addOn.id) + + if (unavailableToolchains.length === toolchains.length) { + return [] + } + + for (const addOn of await selectAddOns( + framework, + config.mode!, + 'toolchain', + 'Which toolchain would you like to add?', + unavailableToolchains, + false, + bundler, + )) { + addOns.add(addOn) + } + return Array.from(addOns) + } + for (const addOn of await selectAddOns( framework, config.mode!, diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 235746ae..fab7edd2 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -2,6 +2,7 @@ import type { PackageManager } from '@tanstack/create' export interface CliOptions { framework?: string + bundler?: string packageManager?: PackageManager toolchain?: string | false deployment?: string diff --git a/packages/cli/src/ui-prompts.ts b/packages/cli/src/ui-prompts.ts index 396adba8..6bbe4c86 100644 --- a/packages/cli/src/ui-prompts.ts +++ b/packages/cli/src/ui-prompts.ts @@ -13,6 +13,9 @@ import { DEFAULT_PACKAGE_MANAGER, SUPPORTED_PACKAGE_MANAGERS, getAllAddOns, + getBundlers, + getDefaultBundler, + resolveBundler, } from '@tanstack/create' import { @@ -24,6 +27,37 @@ import type { AddOn, PackageManager } from '@tanstack/create' import type { Framework } from '@tanstack/create/dist/types/types.js' +export async function selectBundler( + framework: Framework, + bundlerId?: string, +): Promise { + if (bundlerId) { + return resolveBundler(framework, bundlerId).id + } + + const bundlers = getBundlers(framework) + if (bundlers.length === 1) { + return bundlers[0].id + } + + const selected = await select({ + message: 'Select bundler:', + options: bundlers.map((bundler) => ({ + value: bundler.id, + label: bundler.name, + hint: bundler.description, + })), + initialValue: getDefaultBundler(framework).id, + }) + + if (isCancel(selected)) { + cancel('Operation cancelled.') + process.exit(0) + } + + return resolveBundler(framework, selected).id +} + export async function selectFramework( frameworks: Array, defaultFrameworkId?: string, @@ -147,8 +181,9 @@ export async function selectAddOns( message: string, forcedAddOns: Array = [], allowMultiple: boolean = true, + bundlerId?: string, ): Promise> { - const allAddOns = await getAllAddOns(framework, mode) + const allAddOns = await getAllAddOns(framework, mode, bundlerId) const addOns = allAddOns.filter((addOn) => addOn.type === type) if (addOns.length === 0) { return [] diff --git a/packages/cli/tests-e2e/helpers.ts b/packages/cli/tests-e2e/helpers.ts index 091ed028..d02f85f7 100644 --- a/packages/cli/tests-e2e/helpers.ts +++ b/packages/cli/tests-e2e/helpers.ts @@ -1,7 +1,7 @@ import { spawn } from 'node:child_process' import { access, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { dirname, join, resolve } from 'node:path' +import { delimiter, dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import type { Page } from '@playwright/test' @@ -22,6 +22,7 @@ export type E2EApp = { type CreateAppFixtureOptions = { appName: string framework?: 'react' | 'solid' + bundler?: 'vite' | 'rsbuild' packageManager?: 'pnpm' | 'npm' | 'yarn' | 'bun' | 'deno' blank?: boolean deployment?: string @@ -48,12 +49,17 @@ function runCommand( }, ) { return new Promise((resolvePromise, rejectPromise) => { + const env = { + ...process.env, + ...opts.env, + } + env.PATH = [dirname(process.execPath), env.PATH] + .filter(Boolean) + .join(delimiter) + const child = spawn(command, args, { cwd: opts.cwd, - env: { - ...process.env, - ...opts.env, - }, + env, stdio: 'pipe', }) @@ -361,6 +367,7 @@ export async function createAppFixture( skipDevServer, runQualityGatesChecks = false, framework = 'react', + bundler = 'vite', packageManager = 'pnpm', routerOnly = false, } = options @@ -375,6 +382,8 @@ export async function createAppFixture( appName, '--framework', framework, + '--bundler', + bundler, '--package-manager', packageManager, '--no-git', @@ -403,7 +412,7 @@ export async function createAppFixture( try { const createStartedAt = now() await runCommand( - 'node', + process.execPath, createArgs, { cwd: rootDir, @@ -420,7 +429,7 @@ export async function createAppFixture( if (postCreateAddOns?.length) { const postAddOnsStartedAt = now() - await runCommand('node', [cliDistPath, 'add', ...postCreateAddOns], { + await runCommand(process.execPath, [cliDistPath, 'add', ...postCreateAddOns], { cwd: appDir, env: { CI: '1', @@ -463,6 +472,9 @@ export async function createAppFixture( env: { ...process.env, CI: '1', + PATH: [dirname(process.execPath), process.env.PATH] + .filter(Boolean) + .join(delimiter), }, stdio: 'pipe', detached: true, diff --git a/packages/cli/tests-e2e/rsbuild-smoke.spec.ts b/packages/cli/tests-e2e/rsbuild-smoke.spec.ts new file mode 100644 index 00000000..fc7cdd3e --- /dev/null +++ b/packages/cli/tests-e2e/rsbuild-smoke.spec.ts @@ -0,0 +1,125 @@ +import { execFile } from 'node:child_process' +import { access, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' + +import { expect, test } from '@playwright/test' + +import { + attachRuntimeGuards, + createAppFixture, + getRepoPath, + optimizePageForFastE2E, +} from './helpers' + +const execFileAsync = promisify(execFile) + +const scenarios = [ + { framework: 'react', routerOnly: false, blank: false }, + { framework: 'solid', routerOnly: false, blank: false }, + { framework: 'react', routerOnly: true, blank: true }, + { framework: 'solid', routerOnly: true, blank: true }, +] as const + +test('@matrix lists only toolchains for Rsbuild', async () => { + const { stdout } = await execFileAsync( + process.execPath, + [ + getRepoPath('packages/cli/dist/index.js'), + 'create', + '--framework', + 'React', + '--bundler', + 'rsbuild', + '--list-add-ons', + '--json', + ], + { encoding: 'utf8' }, + ) + const addOns = JSON.parse(stdout) as Array<{ type: string }> + + expect(addOns.length).toBeGreaterThan(0) + expect(addOns.every((addOn) => addOn.type === 'toolchain')).toBe(true) +}) + +test('@matrix rejects an Rsbuild deployment before creating a directory', async () => { + const rootDir = await mkdtemp(join(tmpdir(), 'tanstack-rsbuild-conflict-')) + const appName = 'invalid-rsbuild-app' + + try { + let failure: unknown + try { + await execFileAsync( + process.execPath, + [ + getRepoPath('packages/cli/dist/index.js'), + 'create', + appName, + '--bundler', + 'rsbuild', + '--deployment', + 'cloudflare', + '--yes', + '--no-install', + '--no-git', + ], + { cwd: rootDir, encoding: 'utf8' }, + ) + } catch (error) { + failure = error + } + + const commandError = failure as + | (Error & { stdout?: string; stderr?: string }) + | undefined + expect( + `${commandError?.stdout ?? ''}\n${commandError?.stderr ?? ''}`, + ).toContain('Rsbuild does not currently support --deployment') + await expect(access(join(rootDir, appName))).rejects.toThrow() + } finally { + await rm(rootDir, { recursive: true, force: true }) + } +}) + +test.describe('@matrix Rsbuild smoke matrix', () => { + for (const scenario of scenarios) { + test(`@matrix ${scenario.framework} routerOnly=${scenario.routerOnly} blank=${scenario.blank}`, async ({ + page, + }) => { + const fixture = await createAppFixture({ + appName: `${scenario.framework}-rsbuild-${scenario.routerOnly ? 'router' : 'start'}-smoke`, + framework: scenario.framework, + bundler: 'rsbuild', + routerOnly: scenario.routerOnly, + blank: scenario.blank, + packageManager: 'pnpm', + runQualityGatesChecks: true, + }) + const guards = attachRuntimeGuards(page, fixture.url) + + try { + await optimizePageForFastE2E(page) + await page.goto(fixture.url) + await expect(page.locator('body')).toBeVisible() + await expect( + page.getByRole('heading', { + name: scenario.blank + ? 'Welcome to TanStack Start' + : scenario.framework === 'react' + ? 'Start simple, ship quickly.' + : /TANSTACK/i, + }), + ).toBeVisible() + } finally { + try { + guards.assertClean() + } finally { + guards.dispose() + await fixture.stop() + await fixture.cleanup() + } + } + }) + } +}) diff --git a/packages/cli/tests/command-line.test.ts b/packages/cli/tests/command-line.test.ts index e859b8bc..5a98ccd3 100644 --- a/packages/cli/tests/command-line.test.ts +++ b/packages/cli/tests/command-line.test.ts @@ -600,6 +600,100 @@ describe('normalizeOptions', () => { expect(options?.intent).toBe(false) }) + it('normalizes Rsbuild and keeps toolchain add-ons', async () => { + __testRegisterFramework({ + id: 'react', + name: 'React', + bundlers: [ + { id: 'vite', name: 'Vite', description: 'Build with Vite' }, + { + id: 'rsbuild', + name: 'Rsbuild', + description: 'Build with Rsbuild', + }, + ], + defaultBundler: 'vite', + getAddOns: () => [ + { + id: 'biome', + name: 'Biome', + modes: ['file-router'], + type: 'toolchain', + }, + ], + }) + + const options = await normalizeOptions({ + projectName: 'test', + framework: 'react', + bundler: 'rsbuild', + toolchain: 'biome', + }) + + expect(options?.bundler).toBe('rsbuild') + expect(options?.chosenAddOns.map((addOn) => addOn.id)).toEqual(['biome']) + }) + + it.each([ + ['file-router', false], + ['typescript', true], + ['tsx', true], + ])( + 'normalizes Rsbuild with the legacy %s template alias', + async (template, routerOnly) => { + __testRegisterFramework({ + id: 'react', + name: 'React', + bundlers: [ + { id: 'vite', name: 'Vite', description: 'Build with Vite' }, + { + id: 'rsbuild', + name: 'Rsbuild', + description: 'Build with Rsbuild', + }, + ], + defaultBundler: 'vite', + getAddOns: () => [], + }) + + const options = await normalizeOptions({ + projectName: 'test', + framework: 'react', + bundler: 'rsbuild', + template, + }) + + expect(options?.bundler).toBe('rsbuild') + expect(options?.routerOnly).toBe(routerOnly) + }, + ) + + it('rejects unsupported Rsbuild add-ons during normalization', async () => { + __testRegisterFramework({ + id: 'react', + name: 'React', + bundlers: [ + { id: 'vite', name: 'Vite', description: 'Build with Vite' }, + { + id: 'rsbuild', + name: 'Rsbuild', + description: 'Build with Rsbuild', + }, + ], + defaultBundler: 'vite', + getAddOns: () => [], + }) + + await expect( + normalizeOptions({ + projectName: 'test', + framework: 'react', + bundler: 'rsbuild', + addOns: ['form'], + }), + ).rejects.toThrow('Rsbuild currently supports toolchains only') + }) + it('keeps explicitly selected integrations in the blank preset', async () => { __testRegisterFramework({ id: 'react', @@ -729,6 +823,46 @@ describe('validateLegacyCreateFlags', () => { expect(result.error).toBeUndefined() }) + it.each([ + [{ starter: 'blog' }, '--starter'], + [{ template: 'blog' }, '--template'], + [{ templateId: 'blog' }, '--template-id'], + [{ deployment: 'cloudflare' }, '--deployment'], + [{ addOns: ['form'] }, 'toolchains only'], + ])( + 'rejects unsupported Rsbuild combinations containing %s', + (flags, text) => { + const result = validateLegacyCreateFlags({ + bundler: 'rsbuild', + ...flags, + }) + + expect(result.error).toContain(text) + }, + ) + + it('allows Rsbuild with a toolchain', () => { + const result = validateLegacyCreateFlags({ + bundler: 'rsbuild', + toolchain: 'biome', + }) + + expect(result.error).toBeUndefined() + }) + + it.each(['file-router', 'typescript', 'tsx'])( + 'allows Rsbuild with the legacy %s template alias', + (template) => { + const result = validateLegacyCreateFlags({ + bundler: 'rsbuild', + template, + }) + + expect(result.error).toBeUndefined() + expect(result.warnings[0]).toContain('--template') + }, + ) + it.each([ [{ starter: 'ecommerce' }, '--starter'], [{ template: 'ecommerce' }, '--template'], diff --git a/packages/cli/tests/dev-watch.test.ts b/packages/cli/tests/dev-watch.test.ts new file mode 100644 index 00000000..ae0b8c6c --- /dev/null +++ b/packages/cli/tests/dev-watch.test.ts @@ -0,0 +1,80 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { describe, expect, it } from 'vitest' +import { + createMemoryEnvironment, + createReactFrameworkDefinition, +} from '@tanstack/create' + +import { DevWatchManager } from '../src/dev-watch' + +import type { + Framework, + FrameworkDefinition, + Options, +} from '@tanstack/create' + +function frameworkFromDefinition(definition: FrameworkDefinition): Framework { + const { addOns, base, ...framework } = definition + + return { + ...framework, + getFiles: () => Promise.resolve(Object.keys(base)), + getFileContents: (path) => Promise.resolve(base[path]), + getDeletedFiles: () => Promise.resolve([]), + getAddOns: () => addOns, + } +} + +describe('DevWatchManager', () => { + it('preserves bundler metadata when rebuilding a watched framework', async () => { + const watchPath = await mkdtemp(join(tmpdir(), 'tanstack-dev-watch-')) + const baseDirectory = join(watchPath, 'project', 'base') + await mkdir(baseDirectory, { recursive: true }) + await writeFile(join(baseDirectory, 'package.json'), '{}') + + try { + const framework = frameworkFromDefinition( + createReactFrameworkDefinition(), + ) + const { environment } = createMemoryEnvironment() + const cliOptions: Options = { + projectName: 'dev-watch-app', + targetDir: join(watchPath, 'target'), + framework, + mode: 'file-router', + bundler: 'rsbuild', + typescript: true, + tailwind: true, + packageManager: 'npm', + git: false, + install: true, + intent: false, + chosenAddOns: [], + addOnOptions: {}, + } + const manager = new DevWatchManager({ + watchPath, + targetDir: cliOptions.targetDir, + framework, + cliOptions, + packageManager: 'npm', + environment, + }) + const refreshedFramework = ( + manager as unknown as { + createFrameworkDefinitionFromWatchPath: () => FrameworkDefinition | null + } + ).createFrameworkDefinitionFromWatchPath() + + expect(refreshedFramework?.bundlers).toEqual(framework.bundlers) + expect(refreshedFramework?.defaultBundler).toBe( + framework.defaultBundler, + ) + } finally { + await rm(watchPath, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/cli/tests/options.test.ts b/packages/cli/tests/options.test.ts index 826af6dd..3307c353 100644 --- a/packages/cli/tests/options.test.ts +++ b/packages/cli/tests/options.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, it, expect, vi } from 'vitest' import { resolve } from 'node:path' -import { promptForCreateOptions } from '../src/options' +import { promptForAddOns, promptForCreateOptions } from '../src/options' import { __testClearFrameworks, __testRegisterFramework, @@ -27,6 +27,11 @@ beforeEach(() => { __testRegisterFramework({ id: 'react', name: 'react', + bundlers: [ + { id: 'vite', name: 'Vite', description: 'Build with Vite' }, + { id: 'rsbuild', name: 'Rsbuild', description: 'Build with Rsbuild' }, + ], + defaultBundler: 'vite', getAddOns: () => [ { id: 'react-query', @@ -42,6 +47,13 @@ beforeEach(() => { id: 'biome', type: 'toolchain', modes: ['file-router'], + exclusive: ['linter'], + }, + { + id: 'eslint', + type: 'toolchain', + modes: ['file-router'], + exclusive: ['linter'], }, ], supportedModes: { @@ -56,6 +68,11 @@ beforeEach(() => { __testRegisterFramework({ id: 'solid', name: 'solid', + bundlers: [ + { id: 'vite', name: 'Vite', description: 'Build with Vite' }, + { id: 'rsbuild', name: 'Rsbuild', description: 'Build with Rsbuild' }, + ], + defaultBundler: 'vite', getAddOns: () => [], } as unknown as Framework) }) @@ -69,6 +86,7 @@ const baseCliOptions: CliOptions = { } function setBasicSpies() { + vi.spyOn(prompts, 'selectBundler').mockImplementation(async () => 'vite') vi.spyOn(commandLine, 'listTemplateChoices').mockImplementation(async () => []) vi .spyOn(commandLine, 'resolveStarterSpecifier') @@ -187,6 +205,41 @@ describe('promptForCreateOptions', () => { description: 'Blog template', }, ]) + expect( + vi.mocked(prompts.selectBundler).mock.invocationCallOrder[0], + ).toBeLessThan( + vi.mocked(prompts.selectTemplate).mock.invocationCallOrder[0], + ) + }) + + it('selects Rsbuild before templates and skips unsupported prompts', async () => { + setBasicSpies() + vi.mocked(prompts.selectBundler).mockResolvedValue('rsbuild') + vi.mocked(prompts.selectToolchain).mockResolvedValue('biome') + + const options = await promptForCreateOptions( + { ...baseCliOptions, bundler: 'rsbuild', addOns: undefined }, + {}, + ) + + expect(options?.bundler).toBe('rsbuild') + expect(options?.chosenAddOns.map((addOn) => addOn.id)).toEqual(['biome']) + expect(prompts.selectTemplate).not.toHaveBeenCalled() + expect(prompts.selectDeployment).not.toHaveBeenCalled() + expect(prompts.selectAddOns).not.toHaveBeenCalled() + }) + + it('keeps toolchain selection available for blank Rsbuild projects', async () => { + setBasicSpies() + vi.mocked(prompts.selectBundler).mockResolvedValue('rsbuild') + + const options = await promptForCreateOptions( + { ...baseCliOptions, bundler: 'rsbuild', blank: true }, + {}, + ) + + expect(options?.projectPreset).toBe('blank') + expect(prompts.selectToolchain).toHaveBeenCalled() }) it('skips template prompt when template was provided via CLI', async () => { @@ -327,3 +380,20 @@ describe('promptForCreateOptions', () => { expect(options?.typescript).toBe(true) }) }) + +describe('promptForAddOns', () => { + it('does not offer a second exclusive Rsbuild toolchain', async () => { + vi.mocked(prompts.selectAddOns).mockClear() + vi.spyOn(create, 'readConfigFile').mockResolvedValue({ + projectName: 'test', + framework: 'react', + bundler: 'rsbuild', + mode: 'file-router', + chosenAddOns: ['eslint'], + version: 1, + }) + + await expect(promptForAddOns()).resolves.toEqual([]) + expect(prompts.selectAddOns).not.toHaveBeenCalled() + }) +}) diff --git a/packages/cli/tests/telemetry.test.ts b/packages/cli/tests/telemetry.test.ts index f2b3ec41..2b67aa20 100644 --- a/packages/cli/tests/telemetry.test.ts +++ b/packages/cli/tests/telemetry.test.ts @@ -4,12 +4,18 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getTelemetryStatus, setTelemetryEnabled } from '../src/telemetry-config.js' +import { + getCreateTelemetryProperties, + getResolvedCreateTelemetryProperties, +} from '../src/cli.js' import { createTelemetryClient, resetTelemetryStateForTests, } from '../src/telemetry.js' import { createUIEnvironment } from '../src/ui-environment.js' +import type { Framework, Options } from '@tanstack/create' + const TELEMETRY_NUMERIC_PREFIX = 'epn.' const TELEMETRY_STRING_PREFIX = 'ep.' const TELEMETRY_TRANSPORT_PATH = '/g/collect' @@ -135,6 +141,27 @@ describe('telemetry', () => { expect(completedEvent.properties.result).toBe('success') }) + it('records raw and resolved bundler selections', () => { + expect( + getCreateTelemetryProperties('app', { bundler: 'rsbuild' }).bundler, + ).toBe('rsbuild') + + const finalOptions = { + framework: { id: 'react' } as Framework, + bundler: 'rsbuild', + chosenAddOns: [], + projectPreset: 'default', + git: false, + install: false, + intent: false, + packageManager: 'pnpm', + } as Options + + expect(getResolvedCreateTelemetryProperties(finalOptions, {}).bundler).toBe( + 'rsbuild', + ) + }) + it('captures failure events with a coarse error code', async () => { const telemetry = await createTelemetryClient({ json: true }) diff --git a/packages/cli/tests/ui-prompts.test.ts b/packages/cli/tests/ui-prompts.test.ts index 6d34174b..22a0f1fb 100644 --- a/packages/cli/tests/ui-prompts.test.ts +++ b/packages/cli/tests/ui-prompts.test.ts @@ -5,6 +5,7 @@ import * as clack from '@clack/prompts' import { getProjectName, selectAddOns, + selectBundler, selectGit, selectPackageManager, selectTemplate, @@ -90,6 +91,39 @@ describe('selectPackageManager', () => { }) }) +describe('selectBundler', () => { + const framework = { + name: 'React', + bundlers: [ + { id: 'vite', name: 'Vite', description: 'Build with Vite' }, + { id: 'rsbuild', name: 'Rsbuild', description: 'Build with Rsbuild' }, + ], + defaultBundler: 'vite', + } as Framework + + it('defaults the interactive prompt to Vite', async () => { + const selectSpy = vi + .spyOn(clack, 'select') + .mockImplementation(async () => 'rsbuild') + vi.spyOn(clack, 'isCancel').mockImplementation(() => false) + + await expect(selectBundler(framework)).resolves.toBe('rsbuild') + expect(selectSpy).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Select bundler:', + initialValue: 'vite', + }), + ) + }) + + it('validates an explicitly selected bundler without prompting', async () => { + const selectSpy = vi.spyOn(clack, 'select') + + await expect(selectBundler(framework, 'RSBUILD')).resolves.toBe('rsbuild') + expect(selectSpy).not.toHaveBeenCalled() + }) +}) + describe('selectTemplate', () => { it('should select a template id', async () => { vi.spyOn(clack, 'select').mockImplementation(async () => 'blog') diff --git a/packages/create/scripts/generate-manifest.mjs b/packages/create/scripts/generate-manifest.mjs index fdba7e85..674165a9 100644 --- a/packages/create/scripts/generate-manifest.mjs +++ b/packages/create/scripts/generate-manifest.mjs @@ -23,6 +23,11 @@ const frameworkMetadata = { name: 'React', description: 'Templates for React', version: '0.1.0', + bundlers: [ + { id: 'vite', name: 'Vite', description: 'Build with Vite' }, + { id: 'rsbuild', name: 'Rsbuild', description: 'Build with Rsbuild' }, + ], + defaultBundler: 'vite', supportedModes: { 'file-router': { displayName: 'File Router', @@ -36,6 +41,11 @@ const frameworkMetadata = { name: 'Solid', description: 'Solid templates for Tanstack Router Applications', version: '0.1.0', + bundlers: [ + { id: 'vite', name: 'Vite', description: 'Build with Vite' }, + { id: 'rsbuild', name: 'Rsbuild', description: 'Build with Rsbuild' }, + ], + defaultBundler: 'vite', supportedModes: { 'file-router': { displayName: 'File Router', @@ -264,6 +274,7 @@ function createTemplateRendererSource(renderers = templateRenderers) { const { packageManager, projectName, + bundler, typescript, tailwind, blank, @@ -316,6 +327,7 @@ type TemplateRenderContext = { [key: string]: any packageManager: any projectName: any + bundler: any typescript: any tailwind: any blank: any diff --git a/packages/create/src/add-ons.ts b/packages/create/src/add-ons.ts index d3b1ae4c..d608ea7b 100644 --- a/packages/create/src/add-ons.ts +++ b/packages/create/src/add-ons.ts @@ -1,11 +1,20 @@ import { loadRemoteAddOn } from './custom-add-ons/add-on.js' +import { + assertAddOnSupportedByBundler, + isAddOnSupportedByBundler, + resolveBundler, +} from './bundlers.js' import type { AddOn, Framework } from './types.js' -export function getAllAddOns(framework: Framework, mode: string): Array { +export function getAllAddOns(framework: Framework, mode: string, bundlerId?: string): Array { + const bundler = resolveBundler(framework, bundlerId).id return framework .getAddOns() - .filter((a) => a.modes.includes(mode)) + .filter( + (addOn) => + addOn.modes.includes(mode) && isAddOnSupportedByBundler(addOn, bundler), + ) .sort((a, b) => { const aPriority = a.priority ?? 0 const bPriority = b.priority ?? 0 @@ -18,10 +27,14 @@ export async function finalizeAddOns( framework: Framework, mode: string, chosenAddOnIDs: Array, + bundlerId?: string, ): Promise> { + const bundler = resolveBundler(framework, bundlerId).id const finalAddOnIDs = new Set(chosenAddOnIDs) - const addOns = getAllAddOns(framework, mode) + const addOns = framework + .getAddOns() + .filter((addOn) => addOn.modes.includes(mode)) for (const addOnID of finalAddOnIDs) { let addOn: AddOn | undefined @@ -45,11 +58,14 @@ export async function finalizeAddOns( ) } + assertAddOnSupportedByBundler(addOn, bundler) + for (const dependsOn of addOn.dependsOn || []) { const dep = addOns.find((a) => a.id === dependsOn) if (!dep) { throw new Error(`Dependency ${dependsOn} not found`) } + assertAddOnSupportedByBundler(dep, bundler) finalAddOnIDs.add(dep.id) } } diff --git a/packages/create/src/add-to-app.ts b/packages/create/src/add-to-app.ts index 54436c1d..d85f1034 100644 --- a/packages/create/src/add-to-app.ts +++ b/packages/create/src/add-to-app.ts @@ -20,7 +20,7 @@ import { runSpecialSteps } from './special-steps/index.js' import { loadStarter } from './custom-add-ons/starter.js' import { setupIntent } from './integrations/intent.js' -import type { Environment, Options } from './types.js' +import type { AddOn, Environment, Options } from './types.js' import type { PersistedOptions } from './config-file.js' const ENV_FILE_NAMES = new Set(['.env', '.env.local', '.env.example']) @@ -96,16 +96,22 @@ async function createOptions( targetDir: string, ): Promise { const framework = getFrameworkById(json.framework) + const bundler = json.bundler ?? 'vite' const starter = json.starter ? await loadStarter(json.starter) : undefined const chosenAddOns = await finalizeAddOns(framework!, json.mode!, [ ...json.chosenAddOns, ...addOns, - ]) + ], bundler) + + if (bundler === 'rsbuild') { + assertNoExclusiveAddOnConflicts(chosenAddOns) + } return { ...json, framework, + bundler, tailwind: (json.tailwind ?? true) || chosenAddOns.some((addOn) => addOn.tailwind === true), @@ -117,6 +123,22 @@ async function createOptions( } as Options } +function assertNoExclusiveAddOnConflicts(addOns: Array) { + const selectedByExclusiveGroup = new Map() + + for (const addOn of addOns) { + for (const exclusive of addOn.exclusive ?? []) { + const selected = selectedByExclusiveGroup.get(exclusive) + if (selected && selected.id !== addOn.id) { + throw new Error( + `Cannot combine ${selected.name} and ${addOn.name}. Add-ons in the "${exclusive}" group are mutually exclusive.`, + ) + } + selectedByExclusiveGroup.set(exclusive, addOn) + } + } +} + async function runCreateApp(options: Required) { const { environment, output } = createMemoryEnvironment(options.targetDir) await createApp(environment, options) diff --git a/packages/create/src/bundlers.ts b/packages/create/src/bundlers.ts new file mode 100644 index 00000000..b1665c73 --- /dev/null +++ b/packages/create/src/bundlers.ts @@ -0,0 +1,74 @@ +import type { AddOn, BundlerDefinition, Framework, Options } from './types.js' + +export const VITE_BUNDLER: BundlerDefinition = { + id: 'vite', + name: 'Vite', + description: 'Build with Vite', +} + +export function getBundlers(framework: Framework): Array { + return framework.bundlers?.length ? framework.bundlers : [VITE_BUNDLER] +} + +export function getDefaultBundler(framework: Framework): BundlerDefinition { + const bundlers = getBundlers(framework) + return ( + bundlers.find((bundler) => bundler.id === framework.defaultBundler) ?? + bundlers.find((bundler) => bundler.id === VITE_BUNDLER.id) ?? + bundlers[0] + ) +} + +export function resolveBundler( + framework: Framework, + bundlerId?: string, +): BundlerDefinition { + const bundlers = getBundlers(framework) + const requested = bundlerId ?? getDefaultBundler(framework).id + const bundler = + bundlers.find((item) => item.id === requested) ?? + bundlers.find((item) => item.id.toLowerCase() === requested.toLowerCase()) + + if (!bundler) { + throw new Error( + `Bundler "${requested}" is not supported by ${framework.name}. Supported bundlers: ${bundlers.map((item) => item.id).join(', ')}`, + ) + } + + return bundler +} + +export function isAddOnSupportedByBundler( + addOn: Pick, + bundlerId: string, +) { + return bundlerId !== 'rsbuild' || addOn.type === 'toolchain' +} + +export function assertAddOnSupportedByBundler( + addOn: Pick, + bundlerId: string, +) { + if (!isAddOnSupportedByBundler(addOn, bundlerId)) { + throw new Error( + `Rsbuild currently supports toolchain add-ons only. "${addOn.id}" (${addOn.name}) is a ${addOn.type} add-on.`, + ) + } +} + +export function normalizeAndValidateBundlerOptions(options: Options): Options { + const bundler = resolveBundler(options.framework, options.bundler).id + + if (bundler === 'rsbuild' && options.starter) { + throw new Error('Rsbuild does not currently support templates.') + } + + for (const addOn of options.chosenAddOns) { + assertAddOnSupportedByBundler(addOn, bundler) + } + + return { + ...options, + bundler, + } +} diff --git a/packages/create/src/config-file.ts b/packages/create/src/config-file.ts index ab61df25..18ec3a68 100644 --- a/packages/create/src/config-file.ts +++ b/packages/create/src/config-file.ts @@ -72,6 +72,8 @@ export async function readConfigFileFromEnvironment( originalJSON.framework = 'react' } + originalJSON.bundler ??= 'vite' + delete originalJSON.envVarValues return originalJSON diff --git a/packages/create/src/create-app.ts b/packages/create/src/create-app.ts index acb4f970..d300c981 100644 --- a/packages/create/src/create-app.ts +++ b/packages/create/src/create-app.ts @@ -15,6 +15,7 @@ import { installShadcnComponents } from './integrations/shadcn.js' import { setupGit } from './integrations/git.js' import { setupIntent } from './integrations/intent.js' import { runSpecialSteps } from './special-steps/index.js' +import { normalizeAndValidateBundlerOptions } from './bundlers.js' import type { Environment, FileBundleHandler, Options } from './types.js' @@ -474,7 +475,7 @@ Please read the README.md file for information on ${readmeDescription}${errorSta } export async function createApp(environment: Environment, options: Options) { - const effectiveOptions = stripExamplesFromOptions(options) + const effectiveOptions = stripExamplesFromOptions(normalizeAndValidateBundlerOptions(options)) environment.startRun() await writeFiles(environment, effectiveOptions) diff --git a/packages/create/src/custom-add-ons/shared.ts b/packages/create/src/custom-add-ons/shared.ts index d0b1d9e2..536aee2a 100644 --- a/packages/create/src/custom-add-ons/shared.ts +++ b/packages/create/src/custom-add-ons/shared.ts @@ -66,9 +66,10 @@ export async function createAppOptionsFromPersisted( const { version, ...rest } = json /* eslint-enable unused-imports/no-unused-vars */ const framework = getFrameworkById(rest.framework) + const bundler = json.bundler ?? 'vite' const chosenAddOns = await finalizeAddOns(framework!, json.mode!, [ ...json.chosenAddOns, - ]) + ], bundler) return { ...rest, mode: json.mode!, @@ -79,6 +80,7 @@ export async function createAppOptionsFromPersisted( packageManager: json.packageManager!, targetDir: '', framework: framework!, + bundler, starter: json.starter ? await loadStarter(json.starter) : undefined, chosenAddOns, addOnOptions: populateAddOnOptionsDefaults(chosenAddOns), @@ -102,6 +104,7 @@ export function createSerializedOptionsFromPersisted( packageManager: json.packageManager!, targetDir: '', framework: json.framework, + bundler: json.bundler ?? 'vite', starter: json.starter, addOnOptions: {}, intent: json.intent ?? false, diff --git a/packages/create/src/edge-add-ons.ts b/packages/create/src/edge-add-ons.ts index a039811d..608fba3f 100644 --- a/packages/create/src/edge-add-ons.ts +++ b/packages/create/src/edge-add-ons.ts @@ -1,11 +1,20 @@ import { AddOnCompiledSchema } from './types.js' +import { + assertAddOnSupportedByBundler, + isAddOnSupportedByBundler, + resolveBundler, +} from './bundlers.js' import type { AddOn, Framework } from './types.js' -export function getAllAddOns(framework: Framework, mode: string): Array { +export function getAllAddOns(framework: Framework, mode: string, bundlerId?: string): Array { + const bundler = resolveBundler(framework, bundlerId).id return framework .getAddOns() - .filter((a) => a.modes.includes(mode)) + .filter( + (addOn) => + addOn.modes.includes(mode) && isAddOnSupportedByBundler(addOn, bundler), + ) .sort((a, b) => { const aPriority = a.priority ?? 0 const bPriority = b.priority ?? 0 @@ -17,9 +26,13 @@ export async function finalizeAddOns( framework: Framework, mode: string, chosenAddOnIDs: Array, + bundlerId?: string, ): Promise> { + const bundler = resolveBundler(framework, bundlerId).id const finalAddOnIDs = new Set(chosenAddOnIDs) - const addOns = getAllAddOns(framework, mode) + const addOns = framework + .getAddOns() + .filter((addOn) => addOn.modes.includes(mode)) for (const addOnID of finalAddOnIDs) { let addOn: AddOn | undefined @@ -43,11 +56,14 @@ export async function finalizeAddOns( ) } + assertAddOnSupportedByBundler(addOn, bundler) + for (const dependsOn of addOn.dependsOn || []) { const dep = addOns.find((a) => a.id === dependsOn) if (!dep) { throw new Error(`Dependency ${dependsOn} not found`) } + assertAddOnSupportedByBundler(dep, bundler) finalAddOnIDs.add(dep.id) } } diff --git a/packages/create/src/edge-create-app.ts b/packages/create/src/edge-create-app.ts index 624ccdfd..e0ac9465 100644 --- a/packages/create/src/edge-create-app.ts +++ b/packages/create/src/edge-create-app.ts @@ -11,6 +11,7 @@ import { packageManagerInstall, translateExecuteCommand, } from './package-manager.js' +import { normalizeAndValidateBundlerOptions } from './bundlers.js' import type { Environment, FileBundleHandler, Options } from './types.js' @@ -589,7 +590,7 @@ Please read the README.md file for information on ${readmeDescription}${errorSta } export async function createApp(environment: Environment, options: Options) { - const effectiveOptions = stripExamplesFromOptions(options) + const effectiveOptions = stripExamplesFromOptions(normalizeAndValidateBundlerOptions(options)) environment.startRun() await writeFiles(environment, effectiveOptions) diff --git a/packages/create/src/edge-package-json.ts b/packages/create/src/edge-package-json.ts index 56174e0c..5d4585c8 100644 --- a/packages/create/src/edge-package-json.ts +++ b/packages/create/src/edge-package-json.ts @@ -96,6 +96,7 @@ export function mergePackageJSON( export function createPackageJSON(options: Options) { const packageManager = options.packageManager + const bundler = options.bundler ?? 'vite' const blank = options.projectPreset === 'blank' const includeDevtools = !blank || @@ -121,14 +122,24 @@ export function createPackageJSON(options: Options) { const additions: Array | undefined> = [ options.framework.optionalPackages.typescript, + options.framework.optionalPackages.bundlers?.[bundler], includeDevtools ? options.framework.optionalPackages.devtools : undefined, + includeDevtools + ? options.framework.optionalPackages.bundlerDevtools?.[bundler] + : undefined, !blank && options.includeExamples !== false ? options.framework.optionalPackages.examples : undefined, options.tailwind ? options.framework.optionalPackages.tailwindcss : undefined, + options.tailwind + ? options.framework.optionalPackages.bundlerTailwindcss?.[bundler] + : undefined, options.mode ? options.framework.optionalPackages[options.mode] : undefined, + options.mode === 'file-router' + ? options.framework.optionalPackages.bundlerFileRouter?.[bundler] + : undefined, ] for (const addition of additions.filter( (addition): addition is Record => Boolean(addition), @@ -145,6 +156,7 @@ export function createPackageJSON(options: Options) { const templateValues = { packageManager: options.packageManager, projectName: options.projectName, + bundler, typescript: true, tailwind: options.tailwind, blank, @@ -196,6 +208,9 @@ export function createPackageJSON(options: Options) { const scripts = packageJSON.scripts as Record | undefined if (options.routerOnly) { + delete dependencies?.srvx + delete scripts?.start + if (options.framework.id === 'react') { delete dependencies?.['@tanstack/react-start'] delete dependencies?.['@tanstack/react-router-ssr-query'] @@ -209,7 +224,6 @@ export function createPackageJSON(options: Options) { if (options.framework.id === 'solid') { delete dependencies?.['@tanstack/solid-start'] delete dependencies?.['@tanstack/solid-router-ssr-query'] - delete scripts?.start packageJSON.devDependencies = { ...(devDependencies ?? {}), '@tanstack/router-plugin': diff --git a/packages/create/src/edge-render.ts b/packages/create/src/edge-render.ts index 6133bf9f..3df7117c 100644 --- a/packages/create/src/edge-render.ts +++ b/packages/create/src/edge-render.ts @@ -7,6 +7,7 @@ export type TemplateRenderContext = { [key: string]: unknown packageManager: PackageManager | undefined projectName: string | undefined + bundler: string typescript: boolean | undefined tailwind: boolean | undefined blank: boolean | undefined @@ -91,6 +92,7 @@ function renderWithRenderer( return renderer(template, { packageManager: undefined, projectName: undefined, + bundler: 'vite', typescript: undefined, tailwind: undefined, blank: undefined, diff --git a/packages/create/src/edge-template-file.ts b/packages/create/src/edge-template-file.ts index 916a2342..a6a98d99 100644 --- a/packages/create/src/edge-template-file.ts +++ b/packages/create/src/edge-template-file.ts @@ -127,6 +127,7 @@ export function createTemplateFile(environment: Environment, options: Options) { const templateValues = { packageManager: options.packageManager, projectName: options.projectName, + bundler: options.bundler ?? 'vite', typescript: true, tailwind: options.tailwind, blank: options.projectPreset === 'blank', diff --git a/packages/create/src/edge.ts b/packages/create/src/edge.ts index fc65f7f1..2f3031bc 100644 --- a/packages/create/src/edge.ts +++ b/packages/create/src/edge.ts @@ -14,6 +14,15 @@ export { loadRemoteAddOn, populateAddOnOptionsDefaults, } from './edge-add-ons.js' +export { + assertAddOnSupportedByBundler, + getBundlers, + getDefaultBundler, + isAddOnSupportedByBundler, + normalizeAndValidateBundlerOptions, + resolveBundler, + VITE_BUNDLER, +} from './bundlers.js' export { createSerializedOptions } from './options.js' export { CONFIG_FILE } from './constants.js' export { @@ -35,6 +44,7 @@ export type { FileBundleHandler, Framework, FrameworkDefinition, + BundlerDefinition, Options, SerializedOptions, Starter, diff --git a/packages/create/src/frameworks/react/index.ts b/packages/create/src/frameworks/react/index.ts index 6c0b4bdd..cdfcaa30 100644 --- a/packages/create/src/frameworks/react/index.ts +++ b/packages/create/src/frameworks/react/index.ts @@ -33,6 +33,19 @@ export function createFrameworkDefinition(): FrameworkDefinition { addOns, basePackageJSON, optionalPackages, + bundlers: [ + { + id: 'vite', + name: 'Vite', + description: 'Build with Vite', + }, + { + id: 'rsbuild', + name: 'Rsbuild', + description: 'Build with Rsbuild', + }, + ], + defaultBundler: 'vite', supportedModes: { 'file-router': { displayName: 'File Router', diff --git a/packages/create/src/frameworks/react/project/base/README.md.ejs b/packages/create/src/frameworks/react/project/base/README.md.ejs index b1e1d980..5937aa4e 100644 --- a/packages/create/src/frameworks/react/project/base/README.md.ejs +++ b/packages/create/src/frameworks/react/project/base/README.md.ejs @@ -49,8 +49,8 @@ If you prefer not to use Tailwind CSS: 1. Remove the demo pages in `src/routes/demo/` 2. Replace the Tailwind import in `src/styles.css` with your own styles -3. Remove `tailwindcss()` from the plugins array in `vite.config.ts` -4. Remove `@tailwindcss/vite` and `tailwindcss` from `package.json` +3. Remove `<%= bundler === 'vite' ? 'tailwindcss()' : 'pluginTailwindcss()' %>` from the plugins array in `<%= bundler %>.config.ts` +4. Remove `<%= bundler === 'vite' ? '@tailwindcss/vite' : '@rsbuild/plugin-tailwindcss' %>` and `tailwindcss` from `package.json` <% if (addOnEnabled.biome || addOnEnabled.eslint) { %> ## Linting & Formatting <% if (addOnEnabled.biome) { %> diff --git a/packages/create/src/frameworks/react/project/base/index.html.ejs b/packages/create/src/frameworks/react/project/base/index.html.ejs index 3be4a777..09570460 100644 --- a/packages/create/src/frameworks/react/project/base/index.html.ejs +++ b/packages/create/src/frameworks/react/project/base/index.html.ejs @@ -1,4 +1,4 @@ -<% if (!routerOnly) { ignoreFile() } %> +<% if (!routerOnly || bundler !== 'vite') { ignoreFile() } %> diff --git a/packages/create/src/frameworks/react/project/base/package.json b/packages/create/src/frameworks/react/project/base/package.json index 0b33ecb1..79013bfe 100644 --- a/packages/create/src/frameworks/react/project/base/package.json +++ b/packages/create/src/frameworks/react/project/base/package.json @@ -5,26 +5,13 @@ "imports": { "#/*": "./src/*" }, - "scripts": { - "dev": "vite dev --port 3000", - "generate-routes": "tsr generate", - "build": "vite build", - "preview": "vite preview" - }, + "scripts": {}, "dependencies": { "@tanstack/react-router": "latest", "@tanstack/react-start": "latest", "react": "^19.2.0", "react-dom": "^19.2.0" }, - "devDependencies": { - "@vitejs/plugin-react": "^6.0.1", - "vite": "^8.0.0" - }, - "pnpm": { - "onlyBuiltDependencies": [ - "esbuild", - "lightningcss" - ] - } + "devDependencies": {}, + "pnpm": {} } diff --git a/packages/create/src/frameworks/react/project/base/rsbuild.config.ts.ejs b/packages/create/src/frameworks/react/project/base/rsbuild.config.ts.ejs new file mode 100644 index 00000000..02289a5f --- /dev/null +++ b/packages/create/src/frameworks/react/project/base/rsbuild.config.ts.ejs @@ -0,0 +1,45 @@ +<% if (bundler !== 'rsbuild') { ignoreFile() } -%> +import { defineConfig } from '@rsbuild/core' +import { pluginReact } from '@rsbuild/plugin-react' +<% if (tailwind) { -%> +import { pluginTailwindcss } from '@rsbuild/plugin-tailwindcss' +<% } -%> +<% if (routerOnly) { -%> +import { tanstackRouter } from '@tanstack/router-plugin/rspack' +<% } else { -%> +import { tanstackStart } from '@tanstack/react-start/plugin/rsbuild' +<% } -%> + +export default defineConfig({ + server: { + port: 3000, + }, + <% if (routerOnly) { -%> + source: { + entry: { + index: './src/main.tsx', + }, + }, + html: { + mountId: 'app', + title: '<%= projectName %>', + }, + <% } -%> + plugins: [ + pluginReact(), + <% if (tailwind) { %>pluginTailwindcss(),<% } %> + <% if (!routerOnly) { %>tanstackStart(),<% } %> + ], + <% if (routerOnly) { -%> + tools: { + rspack: { + plugins: [ + tanstackRouter({ + target: 'react', + autoCodeSplitting: true, + }), + ], + }, + }, + <% } -%> +}) diff --git a/packages/create/src/frameworks/react/project/base/tsconfig.json.ejs b/packages/create/src/frameworks/react/project/base/tsconfig.json.ejs index 84c44b5d..d5095606 100644 --- a/packages/create/src/frameworks/react/project/base/tsconfig.json.ejs +++ b/packages/create/src/frameworks/react/project/base/tsconfig.json.ejs @@ -1,5 +1,5 @@ { - <% if (addOnEnabled.eslint) { %>"include": ["**/*.ts", "**/*.tsx", "eslint.config.js", "prettier.config.js", "vite.config.js"], + <% if (addOnEnabled.eslint) { %>"include": ["**/*.ts", "**/*.tsx", "eslint.config.js", "prettier.config.js", "<%= bundler %>.config.js"], <% } else { %>"include": ["**/*.ts", "**/*.tsx"],<% } %> "compilerOptions": { "target": "ES2022", @@ -10,7 +10,7 @@ "@/*": ["./src/*"] }, "lib": ["ES2022", "DOM", "DOM.Iterable"], - "types": ["vite/client"], + "types": ["<%= bundler === 'vite' ? 'vite/client' : '@rsbuild/core/types' %>"], /* Bundler mode */ "moduleResolution": "bundler", diff --git a/packages/create/src/frameworks/react/project/base/vite.config.ts.ejs b/packages/create/src/frameworks/react/project/base/vite.config.ts.ejs index 0a60f457..7dfb4033 100644 --- a/packages/create/src/frameworks/react/project/base/vite.config.ts.ejs +++ b/packages/create/src/frameworks/react/project/base/vite.config.ts.ejs @@ -1,3 +1,4 @@ +<% if (bundler !== 'vite') { ignoreFile() } -%> import { defineConfig } from 'vite' <% const showDevtools = !blank || integrations.some((integration) => integration.type === 'devtools') -%> <% if (showDevtools) { -%> diff --git a/packages/create/src/frameworks/react/project/packages.json b/packages/create/src/frameworks/react/project/packages.json index 5103bfc7..1675fbb1 100644 --- a/packages/create/src/frameworks/react/project/packages.json +++ b/packages/create/src/frameworks/react/project/packages.json @@ -9,7 +9,6 @@ }, "tailwindcss": { "dependencies": { - "@tailwindcss/vite": "^4.1.18", "tailwindcss": "^4.1.18" } }, @@ -17,9 +16,6 @@ "dependencies": { "@tanstack/react-devtools": "latest", "@tanstack/react-router-devtools": "latest" - }, - "devDependencies": { - "@tanstack/devtools-vite": "latest" } }, "examples": { @@ -27,9 +23,63 @@ "@tailwindcss/typography": "^0.5.16" } }, - "file-router": { - "devDependencies": { - "@tanstack/router-cli": "^1.132.0" + "bundlers": { + "vite": { + "scripts": { + "dev": "vite dev --port 3000", + "generate-routes": "tsr generate", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "@vitejs/plugin-react": "^6.0.1", + "vite": "^8.0.0" + }, + "pnpm": { + "onlyBuiltDependencies": ["esbuild", "lightningcss"] + } + }, + "rsbuild": { + "scripts": { + "dev": "rsbuild dev --port 3000", + "generate-routes": "rsbuild build", + "build": "rsbuild build", + "start": "srvx --prod -s ../client dist/server/index.js", + "preview": "rsbuild preview" + }, + "dependencies": { + "srvx": "^0.12.7" + }, + "devDependencies": { + "@rsbuild/core": "^2.1.13", + "@rsbuild/plugin-react": "^2.1.0" + } + } + }, + "bundlerTailwindcss": { + "vite": { + "dependencies": { + "@tailwindcss/vite": "^4.1.18" + } + }, + "rsbuild": { + "devDependencies": { + "@rsbuild/plugin-tailwindcss": "^2.0.3" + } + } + }, + "bundlerDevtools": { + "vite": { + "devDependencies": { + "@tanstack/devtools-vite": "latest" + } + } + }, + "bundlerFileRouter": { + "vite": { + "devDependencies": { + "@tanstack/router-cli": "^1.132.0" + } } } } diff --git a/packages/create/src/frameworks/react/toolchains/biome/assets/biome.json b/packages/create/src/frameworks/react/toolchains/biome/assets/biome.json.ejs similarity index 82% rename from packages/create/src/frameworks/react/toolchains/biome/assets/biome.json rename to packages/create/src/frameworks/react/toolchains/biome/assets/biome.json.ejs index 64b4d31a..e79bec9d 100644 --- a/packages/create/src/frameworks/react/toolchains/biome/assets/biome.json +++ b/packages/create/src/frameworks/react/toolchains/biome/assets/biome.json.ejs @@ -10,8 +10,8 @@ "includes": [ "**/src/**/*", "**/.vscode/**/*", - "**/index.html", - "**/vite.config.ts", + <% if (bundler === 'vite') { %>"**/index.html", + <% } %>"**/<%= bundler %>.config.ts", "!**/src/routeTree.gen.ts", "!**/src/styles.css" ] @@ -33,4 +33,4 @@ } } } - +<% if (bundler) { %><% } %> diff --git a/packages/create/src/frameworks/solid/index.ts b/packages/create/src/frameworks/solid/index.ts index 6c52b208..ef9d0518 100644 --- a/packages/create/src/frameworks/solid/index.ts +++ b/packages/create/src/frameworks/solid/index.ts @@ -33,6 +33,19 @@ export function createFrameworkDefinition(): FrameworkDefinition { addOns, basePackageJSON, optionalPackages, + bundlers: [ + { + id: 'vite', + name: 'Vite', + description: 'Build with Vite', + }, + { + id: 'rsbuild', + name: 'Rsbuild', + description: 'Build with Rsbuild', + }, + ], + defaultBundler: 'vite', supportedModes: { 'file-router': { displayName: 'File Router', diff --git a/packages/create/src/frameworks/solid/project/base/README.md.ejs b/packages/create/src/frameworks/solid/project/base/README.md.ejs index f45a0219..482ea1d5 100644 --- a/packages/create/src/frameworks/solid/project/base/README.md.ejs +++ b/packages/create/src/frameworks/solid/project/base/README.md.ejs @@ -49,8 +49,8 @@ If you prefer not to use Tailwind CSS: 1. Remove the demo pages in `src/routes/demo/` 2. Replace the Tailwind import in `src/styles.css` with your own styles -3. Remove `tailwindcss()` from the plugins array in `vite.config.ts` -4. Remove `@tailwindcss/vite` and `tailwindcss` from `package.json` +3. Remove `<%= bundler === 'vite' ? 'tailwindcss()' : 'pluginTailwindcss()' %>` from the plugins array in `<%= bundler %>.config.ts` +4. Remove `<%= bundler === 'vite' ? '@tailwindcss/vite' : '@rsbuild/plugin-tailwindcss' %>` and `tailwindcss` from `package.json` <% for(const addon of addOns.filter(addon => addon.readme)) { %> <%- addon.readmeIsEjs ? renderTemplate(addon.readme) : addon.readme %> diff --git a/packages/create/src/frameworks/solid/project/base/index.html.ejs b/packages/create/src/frameworks/solid/project/base/index.html.ejs index 3be4a777..09570460 100644 --- a/packages/create/src/frameworks/solid/project/base/index.html.ejs +++ b/packages/create/src/frameworks/solid/project/base/index.html.ejs @@ -1,4 +1,4 @@ -<% if (!routerOnly) { ignoreFile() } %> +<% if (!routerOnly || bundler !== 'vite') { ignoreFile() } %> diff --git a/packages/create/src/frameworks/solid/project/base/package.json b/packages/create/src/frameworks/solid/project/base/package.json index 0c5afcac..8888fda3 100644 --- a/packages/create/src/frameworks/solid/project/base/package.json +++ b/packages/create/src/frameworks/solid/project/base/package.json @@ -2,26 +2,12 @@ "name": "app-ts", "private": true, "type": "module", - "scripts": { - "dev": "vite dev --port 3000", - "generate-routes": "tsr generate", - "build": "vite build", - "start": "node .output/server/index.mjs", - "preview": "vite preview" - }, + "scripts": {}, "dependencies": { "@tanstack/solid-router": "latest", "@tanstack/solid-start": "latest", "solid-js": "^1.9.12" }, - "devDependencies": { - "vite-plugin-solid": "^2.11.12", - "vite": "^8.0.0" - }, - "pnpm": { - "onlyBuiltDependencies": [ - "esbuild", - "lightningcss" - ] - } + "devDependencies": {}, + "pnpm": {} } diff --git a/packages/create/src/frameworks/solid/project/base/rsbuild.config.ts.ejs b/packages/create/src/frameworks/solid/project/base/rsbuild.config.ts.ejs new file mode 100644 index 00000000..126eb707 --- /dev/null +++ b/packages/create/src/frameworks/solid/project/base/rsbuild.config.ts.ejs @@ -0,0 +1,49 @@ +<% if (bundler !== 'rsbuild') { ignoreFile() } -%> +import { defineConfig } from '@rsbuild/core' +import { pluginBabel } from '@rsbuild/plugin-babel' +import { pluginSolid } from '@rsbuild/plugin-solid' +<% if (tailwind) { -%> +import { pluginTailwindcss } from '@rsbuild/plugin-tailwindcss' +<% } -%> +<% if (routerOnly) { -%> +import { tanstackRouter } from '@tanstack/router-plugin/rspack' +<% } else { -%> +import { tanstackStart } from '@tanstack/solid-start/plugin/rsbuild' +<% } -%> + +export default defineConfig({ + server: { + port: 3000, + }, + <% if (routerOnly) { -%> + source: { + entry: { + index: './src/main.tsx', + }, + }, + html: { + mountId: 'app', + title: '<%= projectName %>', + }, + <% } -%> + plugins: [ + pluginBabel({ + include: /\.(?:jsx|tsx)$/, + }), + pluginSolid(), + <% if (tailwind) { %>pluginTailwindcss(),<% } %> + <% if (!routerOnly) { %>tanstackStart(),<% } %> + ], + <% if (routerOnly) { -%> + tools: { + rspack: { + plugins: [ + tanstackRouter({ + target: 'solid', + autoCodeSplitting: true, + }), + ], + }, + }, + <% } -%> +}) diff --git a/packages/create/src/frameworks/solid/project/base/tsconfig.json.ejs b/packages/create/src/frameworks/solid/project/base/tsconfig.json.ejs index 9643b2fb..9baf5a6d 100644 --- a/packages/create/src/frameworks/solid/project/base/tsconfig.json.ejs +++ b/packages/create/src/frameworks/solid/project/base/tsconfig.json.ejs @@ -1,5 +1,5 @@ { - <% if (addOnEnabled.eslint) { %>"include": ["**/*.ts", "**/*.tsx", "eslint.config.js", "prettier.config.js", "vite.config.js"], + <% if (addOnEnabled.eslint) { %>"include": ["**/*.ts", "**/*.tsx", "eslint.config.js", "prettier.config.js", "<%= bundler %>.config.js"], <% } else { %>"include": ["**/*.ts", "**/*.tsx"],<% } %> "compilerOptions": { "target": "ES2022", @@ -7,7 +7,7 @@ "jsxImportSource": "solid-js", "module": "ESNext", "lib": ["ES2022", "DOM", "DOM.Iterable"], - "types": ["vite/client"], + "types": ["<%= bundler === 'vite' ? 'vite/client' : '@rsbuild/core/types' %>"], /* Bundler mode */ "moduleResolution": "bundler", diff --git a/packages/create/src/frameworks/solid/project/base/vite.config.ts.ejs b/packages/create/src/frameworks/solid/project/base/vite.config.ts.ejs index 875ffc02..bda806e4 100644 --- a/packages/create/src/frameworks/solid/project/base/vite.config.ts.ejs +++ b/packages/create/src/frameworks/solid/project/base/vite.config.ts.ejs @@ -1,3 +1,4 @@ +<% if (bundler !== 'vite') { ignoreFile() } -%> import { defineConfig } from "vite"; <% const showDevtools = !blank || integrations.some((integration) => integration.type === 'devtools') -%> <% if (showDevtools) { -%> diff --git a/packages/create/src/frameworks/solid/project/packages.json b/packages/create/src/frameworks/solid/project/packages.json index a8f76660..85bf6018 100644 --- a/packages/create/src/frameworks/solid/project/packages.json +++ b/packages/create/src/frameworks/solid/project/packages.json @@ -6,21 +6,73 @@ }, "tailwindcss": { "dependencies": { - "@tailwindcss/vite": "^4.1.18", "tailwindcss": "^4.1.18" } }, "devtools": { "dependencies": { "@tanstack/solid-router-devtools": "latest" + } + }, + "bundlers": { + "vite": { + "scripts": { + "dev": "vite dev --port 3000", + "generate-routes": "tsr generate", + "build": "vite build", + "start": "node .output/server/index.mjs", + "preview": "vite preview" + }, + "devDependencies": { + "vite-plugin-solid": "^2.11.12", + "vite": "^8.0.0" + }, + "pnpm": { + "onlyBuiltDependencies": ["esbuild", "lightningcss"] + } }, - "devDependencies": { - "@tanstack/devtools-vite": "latest" + "rsbuild": { + "scripts": { + "dev": "rsbuild dev --port 3000", + "generate-routes": "rsbuild build", + "build": "rsbuild build", + "start": "srvx --prod -s ../client dist/server/index.js", + "preview": "rsbuild preview" + }, + "dependencies": { + "srvx": "^0.12.7" + }, + "devDependencies": { + "@rsbuild/core": "^2.1.13", + "@rsbuild/plugin-babel": "^1.2.1", + "@rsbuild/plugin-solid": "^1.2.2" + } } }, - "file-router": { - "devDependencies": { - "@tanstack/router-cli": "^1.133.21" + "bundlerTailwindcss": { + "vite": { + "dependencies": { + "@tailwindcss/vite": "^4.1.18" + } + }, + "rsbuild": { + "devDependencies": { + "@rsbuild/plugin-tailwindcss": "^2.0.3" + } + } + }, + "bundlerDevtools": { + "vite": { + "devDependencies": { + "@tanstack/devtools-vite": "latest" + } + } + }, + "bundlerFileRouter": { + "vite": { + "devDependencies": { + "@tanstack/router-cli": "^1.133.21" + } } } } diff --git a/packages/create/src/frameworks/solid/toolchains/biome/assets/biome.json.ejs b/packages/create/src/frameworks/solid/toolchains/biome/assets/biome.json.ejs index 38d3bc2c..00338a2c 100644 --- a/packages/create/src/frameworks/solid/toolchains/biome/assets/biome.json.ejs +++ b/packages/create/src/frameworks/solid/toolchains/biome/assets/biome.json.ejs @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.5/schema.json", "vcs": { "enabled": false, "clientKind": "git", @@ -7,16 +7,20 @@ }, "files": { "ignoreUnknown": false, - "ignore": ["src/routeTree.gen.ts"], - "include": ["src/*", ".vscode/*", "index.html", "vite.config.ts"] + "includes": [ + "**/src/**/*", + "**/.vscode/**/*", + <% if (bundler === 'vite') { %>"**/index.html", + <% } %>"**/<%= bundler %>.config.ts", + "!**/src/routeTree.gen.ts", + "!**/src/styles.css" + ] }, "formatter": { "enabled": true, "indentStyle": "tab" }, - "organizeImports": { - "enabled": true - }, + "assist": { "actions": { "source": { "organizeImports": "on" } } }, "linter": { "enabled": true, "rules": { diff --git a/packages/create/src/index.ts b/packages/create/src/index.ts index ff2c43c5..563cae58 100644 --- a/packages/create/src/index.ts +++ b/packages/create/src/index.ts @@ -15,6 +15,16 @@ export { populateAddOnOptionsDefaults, } from './add-ons.js' +export { + assertAddOnSupportedByBundler, + getBundlers, + getDefaultBundler, + isAddOnSupportedByBundler, + normalizeAndValidateBundlerOptions, + resolveBundler, + VITE_BUNDLER, +} from './bundlers.js' + export { loadRemoteAddOn } from './custom-add-ons/add-on.js' export { loadStarter } from './custom-add-ons/starter.js' @@ -97,6 +107,7 @@ export type { FileBundleHandler, Framework, FrameworkDefinition, + BundlerDefinition, Options, SerializedOptions, Starter, diff --git a/packages/create/src/package-json.ts b/packages/create/src/package-json.ts index 892d5094..8a0e0d25 100644 --- a/packages/create/src/package-json.ts +++ b/packages/create/src/package-json.ts @@ -58,6 +58,7 @@ export function mergePackageJSON( export function createPackageJSON(options: Options) { const packageManager = options.packageManager + const bundler = options.bundler ?? 'vite' const blank = options.projectPreset === 'blank' const includeDevtools = !blank || @@ -81,14 +82,24 @@ export function createPackageJSON(options: Options) { const additions: Array | undefined> = [ options.framework.optionalPackages.typescript, + options.framework.optionalPackages.bundlers?.[bundler], includeDevtools ? options.framework.optionalPackages.devtools : undefined, + includeDevtools + ? options.framework.optionalPackages.bundlerDevtools?.[bundler] + : undefined, !blank && options.includeExamples !== false ? options.framework.optionalPackages.examples : undefined, options.tailwind ? options.framework.optionalPackages.tailwindcss : undefined, + options.tailwind + ? options.framework.optionalPackages.bundlerTailwindcss?.[bundler] + : undefined, options.mode ? options.framework.optionalPackages[options.mode] : undefined, + options.mode === 'file-router' + ? options.framework.optionalPackages.bundlerFileRouter?.[bundler] + : undefined, ] for (const addition of additions.filter(Boolean)) { packageJSON = mergePackageJSON(packageJSON, addition) @@ -102,6 +113,7 @@ export function createPackageJSON(options: Options) { const templateValues = { packageManager: options.packageManager, projectName: options.projectName, + bundler, typescript: true, tailwind: options.tailwind, blank, @@ -145,6 +157,9 @@ export function createPackageJSON(options: Options) { } if (options.routerOnly) { + delete packageJSON.dependencies?.srvx + delete packageJSON.scripts?.start + if (options.framework.id === 'react') { delete packageJSON.dependencies?.['@tanstack/react-start'] delete packageJSON.dependencies?.['@tanstack/react-router-ssr-query'] @@ -158,7 +173,6 @@ export function createPackageJSON(options: Options) { if (options.framework.id === 'solid') { delete packageJSON.dependencies?.['@tanstack/solid-start'] delete packageJSON.dependencies?.['@tanstack/solid-router-ssr-query'] - delete packageJSON.scripts?.start packageJSON.devDependencies = { ...(packageJSON.devDependencies ?? {}), '@tanstack/router-plugin': diff --git a/packages/create/src/template-file.ts b/packages/create/src/template-file.ts index 8713c127..6e6ccdee 100644 --- a/packages/create/src/template-file.ts +++ b/packages/create/src/template-file.ts @@ -130,6 +130,7 @@ export function createTemplateFile(environment: Environment, options: Options) { const templateValues = { packageManager: options.packageManager, projectName: options.projectName, + bundler: options.bundler ?? 'vite', typescript: true, tailwind: options.tailwind, blank: options.projectPreset === 'blank', diff --git a/packages/create/src/types.ts b/packages/create/src/types.ts index e6458165..0cff74c8 100644 --- a/packages/create/src/types.ts +++ b/packages/create/src/types.ts @@ -190,6 +190,12 @@ export type AddOn = AddOnCompiled & FileBundleHandler export type Starter = StarterCompiled & FileBundleHandler +export type BundlerDefinition = { + id: string + name: string + description: string +} + export type FrameworkDefinition = { id: string name: string @@ -201,6 +207,9 @@ export type FrameworkDefinition = { basePackageJSON: Record optionalPackages: Record + bundlers?: Array + defaultBundler?: string + supportedModes: Record< string, { @@ -222,6 +231,7 @@ export interface Options { framework: Framework mode: string + bundler?: string typescript: boolean tailwind: boolean diff --git a/packages/create/src/worker.ts b/packages/create/src/worker.ts index ebfd37f2..c5bc22b9 100644 --- a/packages/create/src/worker.ts +++ b/packages/create/src/worker.ts @@ -35,6 +35,16 @@ import type { } from './types.js' import type { MemoryEnvironmentOutput } from './edge-environment.js' +export { + assertAddOnSupportedByBundler, + getBundlers, + getDefaultBundler, + isAddOnSupportedByBundler, + normalizeAndValidateBundlerOptions, + resolveBundler, + VITE_BUNDLER, +} from './bundlers.js' + export function createWorkerManifestLoader( moduleLoader: WorkerManifestModuleLoader, ): WorkerManifestLoader { @@ -90,6 +100,7 @@ export type WorkerCreateAPI = { framework: Framework, mode: string, chosenAddOnIDs: Array, + bundlerId?: string, ) => Promise> populateAddOnOptionsDefaults: typeof populateAddOnOptionsDefaults createApp: (environment: Environment, options: Options) => Promise @@ -275,8 +286,9 @@ export function createWorkerCreate( framework: Framework, mode: string, chosenAddOnIDs: Array, + bundlerId?: string, ) { - const finalized = await finalizeBaseAddOns(framework, mode, chosenAddOnIDs) + const finalized = await finalizeBaseAddOns(framework, mode, chosenAddOnIDs, bundlerId) return Promise.all(finalized.map(materializeAddOn)) } @@ -332,6 +344,7 @@ export type { FileBundleHandler, Framework, FrameworkDefinition, + BundlerDefinition, Options, SerializedOptions, Starter, diff --git a/packages/create/tests/add-to-app.test.ts b/packages/create/tests/add-to-app.test.ts index da7c5476..614e7065 100644 --- a/packages/create/tests/add-to-app.test.ts +++ b/packages/create/tests/add-to-app.test.ts @@ -52,6 +52,11 @@ beforeEach(() => { addOnsDirectories: [], basePackageJSON: {}, optionalPackages: {}, + bundlers: [ + { id: 'vite', name: 'Vite', description: 'Build with Vite' }, + { id: 'rsbuild', name: 'Rsbuild', description: 'Build with Rsbuild' }, + ], + defaultBundler: 'vite', getAddOns: () => [ { id: 'test', @@ -76,6 +81,36 @@ beforeEach(() => { getFileContents: () => Promise.resolve('foo'), getDeletedFiles: () => Promise.resolve([]), }, + { + id: 'eslint', + name: 'ESLint', + description: 'ESLint', + version: '1.0.0', + type: 'toolchain', + phase: 'setup', + modes: ['file-router'], + exclusive: ['linter'], + packageAdditions: {}, + dependsOn: [], + getFiles: () => Promise.resolve([]), + getFileContents: () => Promise.resolve(''), + getDeletedFiles: () => Promise.resolve([]), + }, + { + id: 'biome', + name: 'Biome', + description: 'Biome', + version: '1.0.0', + type: 'toolchain', + phase: 'setup', + modes: ['file-router'], + exclusive: ['linter'], + packageAdditions: {}, + dependsOn: [], + getFiles: () => Promise.resolve([]), + getFileContents: () => Promise.resolve(''), + getDeletedFiles: () => Promise.resolve([]), + }, ], getFiles: () => Promise.resolve(Object.keys(fakeFiles)), getFileContents: (path) => Promise.resolve(fakeFiles[path]), @@ -109,7 +144,7 @@ describe('getCurrentConfiguration', () => { environment.writeFile('/foo/.cta.json', JSON.stringify(configFile, null, 2)) const out = await getCurrentConfiguration(environment, '/foo') - expect(out).toEqual(configFile) + expect(out).toEqual({ ...configFile, bundler: 'vite' }) }) }) @@ -471,4 +506,31 @@ describe('addToApp', () => { const persisted = JSON.parse(await environment.readFile('/foo/.cta.json')) expect(persisted.tailwind).toBe(true) }) + + it('rejects a second exclusive Rsbuild toolchain', async () => { + const { environment } = createMemoryEnvironment('/foo') + environment.startRun() + environment.writeFile( + '/foo/.cta.json', + JSON.stringify( + { + ...fakeCTAJSON, + mode: 'file-router', + bundler: 'rsbuild', + chosenAddOns: ['eslint'], + }, + null, + 2, + ), + ) + + await expect( + addToApp(environment, ['biome'], '/foo', { forced: true }), + ).rejects.toThrow( + 'Cannot combine ESLint and Biome. Add-ons in the "linter" group are mutually exclusive.', + ) + + const persisted = JSON.parse(await environment.readFile('/foo/.cta.json')) + expect(persisted.chosenAddOns).toEqual(['eslint']) + }) }) diff --git a/packages/create/tests/bundlers.test.ts b/packages/create/tests/bundlers.test.ts new file mode 100644 index 00000000..a9638f21 --- /dev/null +++ b/packages/create/tests/bundlers.test.ts @@ -0,0 +1,264 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { finalizeAddOns, getAllAddOns } from '../src/add-ons.js' +import { getBundlers, resolveBundler } from '../src/bundlers.js' +import { createApp } from '../src/create-app.js' +import { createMemoryEnvironment } from '../src/environment.js' +import { createFrameworkDefinition as createReactFrameworkDefinition } from '../src/frameworks/react/index.js' +import { createFrameworkDefinition as createSolidFrameworkDefinition } from '../src/frameworks/solid/index.js' + +import type { + Framework, + FrameworkDefinition, + Options, + Starter, +} from '../src/types.js' + +function frameworkFromDefinition(definition: FrameworkDefinition): Framework { + const { addOns, base, ...framework } = definition + + return { + ...framework, + getFiles: () => Promise.resolve(Object.keys(base)), + getFileContents: (path: string) => Promise.resolve(base[path]), + getDeletedFiles: () => Promise.resolve([]), + getAddOns: () => addOns, + } +} + +function createOptions( + framework: Framework, + bundler: string, + routerOnly: boolean, + blank: boolean, +): Options { + return { + projectName: 'bundler-app', + targetDir: '/bundler-app', + framework, + mode: 'file-router', + bundler, + typescript: true, + tailwind: !blank, + packageManager: 'npm', + git: false, + install: false, + intent: false, + chosenAddOns: [], + addOnOptions: {}, + projectPreset: blank ? 'blank' : 'default', + routerOnly, + includeExamples: !blank, + } +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('bundler project generation', () => { + it.each([ + ['react', createReactFrameworkDefinition], + ['solid', createSolidFrameworkDefinition], + ])( + '%s exposes Vite as the default and Rsbuild as a supported bundler', + (_, createDefinition) => { + const framework = frameworkFromDefinition(createDefinition()) + + expect(getBundlers(framework).map((bundler) => bundler.id)).toEqual([ + 'vite', + 'rsbuild', + ]) + expect(resolveBundler(framework).id).toBe('vite') + }, + ) + + it('treats custom frameworks without bundler metadata as Vite-only', () => { + const framework = { + id: 'custom', + name: 'Custom', + } as Framework + + expect(getBundlers(framework).map((bundler) => bundler.id)).toEqual([ + 'vite', + ]) + expect(() => resolveBundler(framework, 'rsbuild')).toThrow( + 'Bundler "rsbuild" is not supported by Custom', + ) + }) + + it('aligns the Solid Biome schema with the pinned package version', async () => { + const definition = createSolidFrameworkDefinition() + const biome = definition.addOns.find((addOn) => addOn.id === 'biome')! + const version = biome.packageAdditions!.devDependencies?.['@biomejs/biome'] + + expect(version).toBe('2.4.5') + await expect(biome.getFileContents('biome.json.ejs')).resolves.toContain( + `/schemas/${version}/schema.json`, + ) + }) + + it.each([ + ['react', createReactFrameworkDefinition, 'shadcn'], + ['solid', createSolidFrameworkDefinition, 'solid-ui'], + ])( + '%s filters the Rsbuild catalog to toolchains', + async (_, createDefinition, incompatibleAddOn) => { + const framework = frameworkFromDefinition(createDefinition()) + const addOns = getAllAddOns(framework, 'file-router', 'rsbuild') + + expect(addOns.length).toBeGreaterThan(0) + expect(addOns.every((addOn) => addOn.type === 'toolchain')).toBe(true) + await expect( + finalizeAddOns(framework, 'file-router', ['eslint'], 'rsbuild'), + ).resolves.toEqual( + expect.arrayContaining([expect.objectContaining({ id: 'eslint' })]), + ) + await expect( + finalizeAddOns( + framework, + 'file-router', + [incompatibleAddOn], + 'rsbuild', + ), + ).rejects.toThrow('Rsbuild currently supports toolchain add-ons only') + }, + ) + + it.each([ + ['react', createReactFrameworkDefinition], + ['solid', createSolidFrameworkDefinition], + ])( + '%s rejects Rsbuild templates before starting a run', + async (_, createDefinition) => { + const framework = frameworkFromDefinition(createDefinition()) + const options = { + ...createOptions(framework, 'rsbuild', false, false), + starter: { id: 'starter' } as Starter, + } + const { environment, output } = createMemoryEnvironment() + + await expect(createApp(environment, options)).rejects.toThrow( + 'Rsbuild does not currently support templates', + ) + expect(output.files).toEqual({}) + }, + ) + + it.each( + [ + ['react', createReactFrameworkDefinition], + ['solid', createSolidFrameworkDefinition], + ].flatMap(([frameworkId, createDefinition]) => + ['vite', 'rsbuild'].flatMap((bundler) => + [false, true].flatMap((routerOnly) => + [false, true].map((blank) => [ + frameworkId, + createDefinition, + bundler, + routerOnly, + blank, + ]), + ), + ), + ), + )( + '%s generates %s config (routerOnly=%s, blank=%s)', + async (frameworkId, createDefinition, bundler, routerOnly, blank) => { + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response(JSON.stringify({ version: '1.0.0' }), { + status: 200, + }), + ), + ) + + const framework = frameworkFromDefinition( + (createDefinition as () => FrameworkDefinition)(), + ) + const options = createOptions( + framework, + bundler as string, + routerOnly as boolean, + blank as boolean, + ) + const { environment, output } = createMemoryEnvironment() + + await createApp(environment, options) + + const files = Object.fromEntries( + Object.entries(output.files).map(([path, contents]) => [ + path.slice(`${options.targetDir}/`.length), + contents, + ]), + ) + const packageJSON = JSON.parse(files['package.json']) + const config = JSON.parse(files['.cta.json']) + const configFile = `${bundler}.config.ts` + const otherConfig = `${bundler === 'vite' ? 'rsbuild' : 'vite'}.config.ts` + + expect(files).toHaveProperty(configFile) + expect(files).not.toHaveProperty(otherConfig) + expect(config.bundler).toBe(bundler) + expect(packageJSON.scripts.dev).toBe(`${bundler} dev --port 3000`) + expect(packageJSON.scripts.build).toBe(`${bundler} build`) + expect(packageJSON.scripts.preview).toBe(`${bundler} preview`) + expect(packageJSON.scripts['generate-routes']).toBe( + bundler === 'vite' ? 'tsr generate' : 'rsbuild build', + ) + + if (bundler === 'vite') { + expect(packageJSON.devDependencies).toHaveProperty('vite') + expect(packageJSON.devDependencies).not.toHaveProperty('@rsbuild/core') + expect(files['tsconfig.json']).toContain('vite/client') + } else { + expect(packageJSON.devDependencies).toHaveProperty('@rsbuild/core') + expect(packageJSON.devDependencies).not.toHaveProperty('vite') + expect(packageJSON.devDependencies).not.toHaveProperty( + '@tanstack/devtools-vite', + ) + expect(files['tsconfig.json']).toContain('@rsbuild/core/types') + expect(files).not.toHaveProperty('index.html') + } + + if (blank) { + expect(packageJSON.dependencies).not.toHaveProperty('tailwindcss') + expect(packageJSON.devDependencies).not.toHaveProperty( + bundler === 'vite' + ? '@tailwindcss/vite' + : '@rsbuild/plugin-tailwindcss', + ) + } else { + expect(packageJSON.dependencies).toHaveProperty('tailwindcss') + const tailwindPackage = + bundler === 'vite' + ? packageJSON.dependencies['@tailwindcss/vite'] + : packageJSON.devDependencies['@rsbuild/plugin-tailwindcss'] + expect(tailwindPackage).toBeTruthy() + } + + const startPackage = + frameworkId === 'react' + ? '@tanstack/react-start' + : '@tanstack/solid-start' + if (routerOnly) { + expect(packageJSON.dependencies).not.toHaveProperty(startPackage) + expect(packageJSON.devDependencies).toHaveProperty( + '@tanstack/router-plugin', + ) + expect(packageJSON.scripts).not.toHaveProperty('start') + expect(packageJSON.dependencies).not.toHaveProperty('srvx') + expect(files[configFile]).toContain('@tanstack/router-plugin') + } else { + expect(packageJSON.dependencies).toHaveProperty(startPackage) + if (bundler === 'rsbuild') { + expect(packageJSON.scripts.start).toContain('srvx') + expect(packageJSON.dependencies).toHaveProperty('srvx') + expect(files[configFile]).toContain(`${startPackage}/plugin/rsbuild`) + } + } + }, + ) +}) diff --git a/packages/create/tests/config-file.test.ts b/packages/create/tests/config-file.test.ts index 6067c295..601bc3fd 100644 --- a/packages/create/tests/config-file.test.ts +++ b/packages/create/tests/config-file.test.ts @@ -66,7 +66,7 @@ describe('readConfigFileFromEnvironment', () => { JSON.stringify(persistedOptions, null, 2), ) const config = await readConfigFileFromEnvironment(environment, targetDir) - expect(config).toEqual(persistedOptions) + expect(config).toEqual({ ...persistedOptions, bundler: 'vite' }) }) it('should upgrade old config files', async () => { @@ -98,6 +98,7 @@ describe('readConfigFileFromEnvironment', () => { expect(config).toEqual({ framework: 'react', + bundler: 'vite', projectName: 'foo', typescript: false, tailwind: false, diff --git a/packages/create/tests/custom-add-ons/shared.test.ts b/packages/create/tests/custom-add-ons/shared.test.ts index 18267169..63879b05 100644 --- a/packages/create/tests/custom-add-ons/shared.test.ts +++ b/packages/create/tests/custom-add-ons/shared.test.ts @@ -217,6 +217,7 @@ describe('readCurrentProjectOptions', () => { ) const options = await readCurrentProjectOptions(environment) expect(options).toEqual({ + bundler: 'vite', chosenAddOns: [], framework: 'test', git: true, diff --git a/packages/create/tests/edge-manifest.test.ts b/packages/create/tests/edge-manifest.test.ts index d3bc9aa5..c9fee0db 100644 --- a/packages/create/tests/edge-manifest.test.ts +++ b/packages/create/tests/edge-manifest.test.ts @@ -80,6 +80,8 @@ describe('@tanstack/create/edge manifest', () => { expect(edgeFramework?.name).toBe(nodeDefinition.name) expect(edgeFramework?.description).toBe(nodeDefinition.description) expect(edgeFramework?.version).toBe(nodeDefinition.version) + expect(edgeFramework?.bundlers).toEqual(nodeDefinition.bundlers) + expect(edgeFramework?.defaultBundler).toBe(nodeDefinition.defaultBundler) expect(edgeFramework?.supportedModes).toEqual(nodeDefinition.supportedModes) expect(edgeFramework?.basePackageJSON).toEqual( nodeDefinition.basePackageJSON, @@ -93,6 +95,48 @@ describe('@tanstack/create/edge manifest', () => { ) }) + it('renders an Rsbuild app from the edge manifest', async () => { + try { + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response(JSON.stringify({ version: '1.0.0' }), { + status: 200, + }), + ), + ) + + const framework = getFrameworkById('solid') + const { environment, output } = createMemoryEnvironment() + + await createApp(environment, { + projectName: 'rsbuild-app', + targetDir: '/rsbuild-app', + framework: framework!, + mode: 'file-router', + bundler: 'rsbuild', + typescript: true, + tailwind: true, + packageManager: 'pnpm', + git: false, + install: false, + intent: false, + chosenAddOns: [], + addOnOptions: {}, + includeExamples: false, + } satisfies Options) + + expect(output.files).toHaveProperty('/rsbuild-app/rsbuild.config.ts') + expect(output.files).not.toHaveProperty('/rsbuild-app/vite.config.ts') + expect(JSON.parse(output.files['/rsbuild-app/.cta.json']).bundler).toBe( + 'rsbuild', + ) + } finally { + vi.unstubAllGlobals() + } + }) + it('returns the same React add-ons as the Node filesystem-backed path', () => { const nodeFramework = frameworkFromDefinition(createReactFrameworkDefinition()) const edgeFramework = getFrameworkById('react') diff --git a/packages/create/tests/framework-template.test.ts b/packages/create/tests/framework-template.test.ts index 3f79f1e7..97432925 100644 --- a/packages/create/tests/framework-template.test.ts +++ b/packages/create/tests/framework-template.test.ts @@ -24,11 +24,19 @@ describe('framework templates', () => { ])('%s includes route generation tooling', (_, createDefinition) => { const framework = createDefinition() - expect(framework.base['package.json']).toContain( - '"generate-routes": "tsr generate"', - ) expect( - framework.optionalPackages['file-router'].devDependencies, + framework.optionalPackages.bundlers.vite.scripts['generate-routes'], + ).toBe('tsr generate') + expect( + framework.optionalPackages.bundlers.rsbuild.scripts['generate-routes'], + ).toBe('rsbuild build') + expect( + framework.optionalPackages.bundlerFileRouter.vite.devDependencies, ).toHaveProperty('@tanstack/router-cli') + expect(framework.defaultBundler).toBe('vite') + expect(framework.bundlers?.map((bundler) => bundler.id)).toEqual([ + 'vite', + 'rsbuild', + ]) }) }) diff --git a/packages/create/tests/worker-manifest.test.ts b/packages/create/tests/worker-manifest.test.ts index 1ed428d6..f7296907 100644 --- a/packages/create/tests/worker-manifest.test.ts +++ b/packages/create/tests/worker-manifest.test.ts @@ -319,4 +319,55 @@ describe('@tanstack/create/worker manifest loading', () => { vi.unstubAllGlobals() } }) + + it('keeps Rsbuild rendering equal between worker and edge manifests', async () => { + try { + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response(JSON.stringify({ version: '1.0.0' }), { + status: 200, + }), + ), + ) + + const { loader } = createTrackedLoader() + const workerCreate = createWorkerCreate(loader) + const framework = await workerCreate.getFrameworkById('react') + const options = { + projectName: 'rsbuild-worker-app', + targetDir: '/rsbuild-worker-app', + framework: framework!, + mode: 'file-router', + bundler: 'rsbuild', + typescript: true, + tailwind: true, + packageManager: 'pnpm', + git: false, + install: false, + intent: false, + chosenAddOns: [], + addOnOptions: {}, + includeExamples: false, + } satisfies Options + const { environment, output } = createMemoryEnvironment(options.targetDir) + + await workerCreate.createApp(environment, options) + + const edgeFramework = getEdgeFrameworkById('react') + const { environment: edgeEnvironment, output: edgeOutput } = + createEdgeMemoryEnvironment(options.targetDir) + await createEdgeApp(edgeEnvironment, { + ...options, + framework: edgeFramework!, + }) + + expect(output.files).toEqual(edgeOutput.files) + expect(output.files).toHaveProperty('rsbuild.config.ts') + expect(output.files).not.toHaveProperty('vite.config.ts') + } finally { + vi.unstubAllGlobals() + } + }) })