Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const guideSidebar = [
items: [
{ text: 'Lint', link: '/guide/lint' },
{ text: 'Format', link: '/guide/fmt' },
{ text: 'Nested Configuration', link: '/guide/nested-config' },
],
},
{ text: 'Test', link: '/guide/test' },
Expand Down
2 changes: 2 additions & 0 deletions docs/guide/fmt.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ For editors, point the formatter config path at `./vite.config.ts` so format-on-

For the upstream formatter behavior and configuration reference, see the [Oxfmt docs](https://oxc.rs/docs/guide/usage/formatter.html).

In monorepos, `vp fmt` walks up from the current working directory and uses the first `vite.config.ts` it finds — unlike `vp lint`, which is cwd-only. See [Nested Configuration](/guide/nested-config) for the full resolution rules.

```ts
import { defineConfig } from 'vite-plus';

Expand Down
2 changes: 2 additions & 0 deletions docs/guide/lint.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ Put lint configuration directly in the `lint` block in `vite.config.ts` so all y

For the upstream rule set, options, and compatibility details, see the [Oxlint docs](https://oxc.rs/docs/guide/usage/linter.html).

In monorepos, `vp lint` uses `<cwd>/vite.config.ts` if it exists, and falls back to Oxlint's built-in defaults otherwise — it does not walk up. See [Nested Configuration](/guide/nested-config) for details.

```ts
import { defineConfig } from 'vite-plus';

Expand Down
112 changes: 112 additions & 0 deletions docs/guide/nested-config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# Nested Configuration

Vite+ supports multiple `vite.config.ts` files in a repository, so packages in a monorepo can have their own lint and format settings while sharing a baseline.

## How `vp lint` and `vp fmt` pick a config

Config resolution is driven by the current working directory (cwd):

- **`vp lint`** uses `<cwd>/vite.config.ts`. If that file is missing, built-in defaults apply — `vp lint` does **not** walk up the directory tree looking for a parent `vite.config.ts`.
- **`vp fmt`** walks up from cwd and uses the first `vite.config.ts` it finds. If none is found, built-in defaults apply.

In both cases, the selected config applies to every path in the run — there is no per-file resolution, and configs are never merged.

If your monorepo needs different settings per package, run `vp lint` / `vp fmt` from each package directory (for example, via `vp run -r lint`), or pin a specific config with `-c`.

If you only want to exclude files or folders from an otherwise-shared config, use [`lint.ignorePatterns`](/config/lint) or [`fmt.ignorePatterns`](/config/fmt) instead.

::: tip Breaking change since the April 2026 release

Earlier versions of Vite+ pinned every `vp lint` / `vp fmt` invocation to the workspace-root `vite.config.ts`, regardless of cwd. Vite+ now lets cwd-based resolution select the config, so running from a sub-package picks up that sub-package's own `vite.config.ts`. See [#1378](https://github.com/voidzero-dev/vite-plus/pull/1378) for the migration notes.

:::

## Example

Given this layout:

```
my-project/
├── vite.config.ts
├── src/
│ └── index.ts
├── package1/
│ ├── vite.config.ts
│ └── src/index.ts
└── package2/
└── src/index.ts
```

`vp lint`:

| cwd | config used |
| ---------------------------- | ----------------------------------- |
| `my-project/` | `my-project/vite.config.ts` |
| `my-project/package1/` | `my-project/package1/vite.config.ts`|
| `my-project/package1/src/` | built-in defaults (no walk-up) |
| `my-project/package2/` | built-in defaults (no walk-up) |

`vp fmt`:

| cwd | config used |
| ---------------------------- | ------------------------------------ |
| `my-project/` | `my-project/vite.config.ts` |
| `my-project/package1/` | `my-project/package1/vite.config.ts` |
| `my-project/package1/src/` | `my-project/package1/vite.config.ts` |
| `my-project/package2/` | `my-project/vite.config.ts` |

## Pinning a config with `-c`

`-c` / `--config` bypasses cwd-based resolution. The specified file is used for every path in the run:

```bash
vp lint -c vite.config.ts
vp fmt --check -c vite.config.ts
```

This also works when you need a one-off config, for example a permissive CI variant.

## `--disable-nested-config` (lint only)

`vp lint` accepts `--disable-nested-config` to stop any auto-loading of nested lint configuration files that may exist in the tree:

```bash
vp lint --disable-nested-config
vp check --disable-nested-config
```

This flag has no effect on `vite.config.ts` resolution, which is already cwd-only for `vp lint`. `vp fmt` has no equivalent flag; use `-c` to pin a single format config.

## Monorepo pattern: share a base config

Configs are never merged automatically — the selected config fully replaces any other. To share a baseline, import the parent config and spread it:

```ts [my-project/vite.config.ts]
import { defineConfig } from 'vite-plus';

export default defineConfig({
lint: {
rules: {
'no-debugger': 'error',
},
},
});
```

```ts [my-project/package1/vite.config.ts]
import { defineConfig } from 'vite-plus';
import baseConfig from '../vite.config.ts';

export default defineConfig({
...baseConfig,
lint: {
...baseConfig.lint,
rules: {
...baseConfig.lint?.rules,
'no-console': 'off',
},
},
});
```

This keeps the shared baseline in one place and makes package configs small and focused.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"bootstrap-cli:ci": "pnpm install-global-cli",
"install-global-cli": "tool install-global-cli",
"tsgo": "tsgo -b tsconfig.json",
"lint": "vp lint --type-aware --type-check --threads 4",
"check": "vp check --disable-nested-config",
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@leaysgur @camc314 can we support disable nested config in vite.config.ts too?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you're asking whether that applies even when using vite.config.ts in VP mode, I think the answer is yes.

"test": "vp test run && pnpm -r snap-test",
"fmt": "vp fmt",
"test:unit": "vp test run",
Expand Down Expand Up @@ -37,7 +37,7 @@
"zod": "catalog:"
},
"lint-staged": {
"*.@(js|ts|tsx|md|yaml|yml)": "vp check --fix",
"*.@(js|ts|tsx|md|yaml|yml)": "vp check --fix --disable-nested-config",
"*.rs": "cargo fmt --"
},
"engines": {
Expand Down
5 changes: 1 addition & 4 deletions packages/cli/binding/src/check/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ pub(crate) async fn execute_check(
let has_paths = !paths.is_empty();
let mut fmt_fix_started: Option<Instant> = None;
let mut deferred_lint_pass: Option<(String, String)> = None;
let resolved_vite_config = resolver.resolve_universal_vite_config().await?;

if !no_fmt {
let mut args = if fix { vec![] } else { vec!["--check".to_string()] };
Expand All @@ -55,7 +54,6 @@ pub(crate) async fn execute_check(
let captured = resolve_and_capture_output(
resolver,
SynthesizableSubcommand::Fmt { args },
Some(&resolved_vite_config),
envs,
cwd,
cwd_arc,
Expand Down Expand Up @@ -116,6 +114,7 @@ pub(crate) async fn execute_check(
}

if !no_lint {
let resolved_vite_config = resolver.resolve_universal_vite_config().await?;
let lint_message_kind =
LintMessageKind::from_lint_config(resolved_vite_config.lint.as_ref());
let mut args = Vec::new();
Expand All @@ -137,7 +136,6 @@ pub(crate) async fn execute_check(
let captured = resolve_and_capture_output(
resolver,
SynthesizableSubcommand::Lint { args },
Some(&resolved_vite_config),
envs,
cwd,
cwd_arc,
Expand Down Expand Up @@ -204,7 +202,6 @@ pub(crate) async fn execute_check(
let captured = resolve_and_capture_output(
resolver,
SynthesizableSubcommand::Fmt { args },
Some(&resolved_vite_config),
envs,
cwd,
cwd_arc,
Expand Down
24 changes: 6 additions & 18 deletions packages/cli/binding/src/cli/execution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,19 @@ use vite_task::ExitStatus;

use super::{
resolver::SubcommandResolver,
types::{CapturedCommandOutput, ResolvedUniversalViteConfig, SynthesizableSubcommand},
types::{CapturedCommandOutput, SynthesizableSubcommand},
};

/// Resolve a subcommand into a prepared `tokio::process::Command`.
async fn resolve_and_build_command(
resolver: &SubcommandResolver,
subcommand: SynthesizableSubcommand,
resolved_vite_config: Option<&ResolvedUniversalViteConfig>,
envs: &Arc<FxHashMap<Arc<OsStr>, Arc<OsStr>>>,
cwd: &AbsolutePathBuf,
cwd_arc: &Arc<AbsolutePath>,
) -> Result<tokio::process::Command, Error> {
let resolved = resolver
.resolve(subcommand, resolved_vite_config, envs, cwd_arc)
.await
.map_err(|e| Error::Anyhow(e))?;
let resolved =
resolver.resolve(subcommand, envs, cwd_arc).await.map_err(|e| Error::Anyhow(e))?;

// Resolve the program path using `which` to handle Windows .cmd/.bat files (PATHEXT)
let program_path = {
Expand Down Expand Up @@ -52,14 +49,11 @@ async fn resolve_and_build_command(
pub(super) async fn resolve_and_execute(
resolver: &SubcommandResolver,
subcommand: SynthesizableSubcommand,
resolved_vite_config: Option<&ResolvedUniversalViteConfig>,
envs: &Arc<FxHashMap<Arc<OsStr>, Arc<OsStr>>>,
cwd: &AbsolutePathBuf,
cwd_arc: &Arc<AbsolutePath>,
) -> Result<ExitStatus, Error> {
let mut cmd =
resolve_and_build_command(resolver, subcommand, resolved_vite_config, envs, cwd, cwd_arc)
.await?;
let mut cmd = resolve_and_build_command(resolver, subcommand, envs, cwd, cwd_arc).await?;
let mut child = cmd.spawn().map_err(|e| Error::Anyhow(e.into()))?;
let status = child.wait().await.map_err(|e| Error::Anyhow(e.into()))?;
Ok(ExitStatus(status.code().unwrap_or(1) as u8))
Expand All @@ -75,16 +69,13 @@ pub(super) enum FilterStream {
pub(super) async fn resolve_and_execute_with_filter(
resolver: &SubcommandResolver,
subcommand: SynthesizableSubcommand,
resolved_vite_config: Option<&ResolvedUniversalViteConfig>,
envs: &Arc<FxHashMap<Arc<OsStr>, Arc<OsStr>>>,
cwd: &AbsolutePathBuf,
cwd_arc: &Arc<AbsolutePath>,
stream: FilterStream,
filter: impl Fn(&str) -> Cow<'_, str>,
) -> Result<ExitStatus, Error> {
let mut cmd =
resolve_and_build_command(resolver, subcommand, resolved_vite_config, envs, cwd, cwd_arc)
.await?;
let mut cmd = resolve_and_build_command(resolver, subcommand, envs, cwd, cwd_arc).await?;
match stream {
FilterStream::Stdout => cmd.stdout(Stdio::piped()),
FilterStream::Stderr => cmd.stderr(Stdio::piped()),
Expand All @@ -111,15 +102,12 @@ pub(super) async fn resolve_and_execute_with_filter(
pub(crate) async fn resolve_and_capture_output(
resolver: &SubcommandResolver,
subcommand: SynthesizableSubcommand,
resolved_vite_config: Option<&ResolvedUniversalViteConfig>,
envs: &Arc<FxHashMap<Arc<OsStr>, Arc<OsStr>>>,
cwd: &AbsolutePathBuf,
cwd_arc: &Arc<AbsolutePath>,
force_color_if_terminal: bool,
) -> Result<CapturedCommandOutput, Error> {
let mut cmd =
resolve_and_build_command(resolver, subcommand, resolved_vite_config, envs, cwd, cwd_arc)
.await?;
let mut cmd = resolve_and_build_command(resolver, subcommand, envs, cwd, cwd_arc).await?;
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
if force_color_if_terminal && std::io::stdout().is_terminal() {
Expand Down
3 changes: 1 addition & 2 deletions packages/cli/binding/src/cli/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,7 @@ impl CommandHandler for VitePlusCommandHandler {
)))
}
CLIArgs::Synthesizable(subcmd) => {
let resolved =
self.resolver.resolve(subcmd, None, &command.envs, &command.cwd).await?;
let resolved = self.resolver.resolve(subcmd, &command.envs, &command.cwd).await?;
Ok(HandledCommand::Synthesized(resolved.into_synthetic_plan_request()))
}
CLIArgs::ViteTask(cmd) => Ok(HandledCommand::ViteTaskCommand(cmd)),
Expand Down
4 changes: 1 addition & 3 deletions packages/cli/binding/src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,6 @@ async fn execute_direct_subcommand(
resolve_and_execute_with_filter(
&resolver,
other,
None,
&envs,
cwd,
&cwd_arc,
Expand All @@ -94,7 +93,6 @@ async fn execute_direct_subcommand(
resolve_and_execute_with_filter(
&resolver,
other,
None,
&envs,
cwd,
&cwd_arc,
Expand All @@ -103,7 +101,7 @@ async fn execute_direct_subcommand(
)
.await?
} else {
resolve_and_execute(&resolver, other, None, &envs, cwd, &cwd_arc).await?
resolve_and_execute(&resolver, other, &envs, cwd, &cwd_arc).await?
}
}
};
Expand Down
33 changes: 2 additions & 31 deletions packages/cli/binding/src/cli/resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,32 +65,17 @@ impl SubcommandResolver {
pub(super) async fn resolve(
&self,
subcommand: SynthesizableSubcommand,
resolved_vite_config: Option<&ResolvedUniversalViteConfig>,
envs: &Arc<FxHashMap<Arc<OsStr>, Arc<OsStr>>>,
cwd: &Arc<AbsolutePath>,
) -> anyhow::Result<ResolvedSubcommand> {
match subcommand {
SynthesizableSubcommand::Lint { mut args } => {
SynthesizableSubcommand::Lint { args } => {
let cli_options = self.cli_options()?;
let resolved = (cli_options.lint)().await?;
let js_path = resolved.bin_path;
let js_path_str = js_path
.to_str()
.ok_or_else(|| anyhow::anyhow!("lint JS path is not valid UTF-8"))?;
let owned_resolved_vite_config;
let resolved_vite_config = if let Some(config) = resolved_vite_config {
config
} else {
owned_resolved_vite_config = self.resolve_universal_vite_config().await?;
&owned_resolved_vite_config
};

if let (Some(_), Some(config_file)) =
(&resolved_vite_config.lint, &resolved_vite_config.config_file)
{
args.insert(0, "-c".to_string());
args.insert(1, config_file.clone());
}
Comment thread
fengmk2 marked this conversation as resolved.

Ok(ResolvedSubcommand {
program: Arc::from(OsStr::new("node")),
Expand All @@ -106,27 +91,13 @@ impl SubcommandResolver {
envs: merge_resolved_envs_with_version(envs, resolved.envs),
})
}
SynthesizableSubcommand::Fmt { mut args } => {
SynthesizableSubcommand::Fmt { args } => {
let cli_options = self.cli_options()?;
let resolved = (cli_options.fmt)().await?;
let js_path = resolved.bin_path;
let js_path_str = js_path
.to_str()
.ok_or_else(|| anyhow::anyhow!("fmt JS path is not valid UTF-8"))?;
let owned_resolved_vite_config;
let resolved_vite_config = if let Some(config) = resolved_vite_config {
config
} else {
owned_resolved_vite_config = self.resolve_universal_vite_config().await?;
&owned_resolved_vite_config
};

if let (Some(_), Some(config_file)) =
(&resolved_vite_config.fmt, &resolved_vite_config.config_file)
{
args.insert(0, "-c".to_string());
args.insert(1, config_file.clone());
}

Ok(ResolvedSubcommand {
program: Arc::from(OsStr::new("node")),
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/snap-tests/docs-nested-config/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "docs-nested-config-test",
"version": "0.0.0",
"private": true,
"type": "module",
"packageManager": "pnpm@10.16.1"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "pkg-a",
"version": "0.0.0",
"private": true
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
function pkgA() {
debugger;
return "hello from pkg-a";
}

export { pkgA };
Loading
Loading