How Vite+ Merges Multiple Configuration Files During Project Migration

Vite+ detects existing .oxlintrc and .oxfmtrc files, ensures a target vite.config.ts exists, and uses Rust-based AST transformations to inject the JSON configurations under lint and fmt keys before removing the original files.

When migrating a project to the unified voidzero-dev/vite-plus toolchain, consolidating scattered Ox configuration files into a single Vite configuration is essential. The migration CLI implements a three-stage pipeline that automatically discovers legacy configuration files and merges them into vite.config.ts using native Rust bindings and programmatic AST manipulation.

Stage 1: Detecting Ox Configuration Files

The migration begins with config detection in packages/cli/src/migration/detector.ts. The detectConfigs() function (lines 42‑66) scans the project root for existing Vite and Ox configuration files, returning a ConfigFiles object that records which paths exist.

// packages/cli/src/migration/detector.ts
export function detectConfigs(projectPath: string): ConfigFiles {
  const viteConfig = findViteConfig(projectPath);
  const oxlintConfig = findFile(projectPath, ['.oxlintrc', '.oxlintrc.json']);
  const oxfmtConfig  = findFile(projectPath, ['.oxfmtrc',  '.oxfmtrc.json']);
  // ...
  return { viteConfig, oxlintConfig, oxfmtConfig, /* ... */ };
}

This utility looks for exact filenames—including optional .json extensions—and identifies whether the project uses .oxlintrc for linting rules or .oxfmtrc for formatting preferences.

Stage 2: Ensuring a Target Vite Configuration

Before merging can occur, the migrator ensures a valid target exists. In packages/cli/src/migration/migrator.ts, the ensureViteConfig() function (around line 430) checks the ConfigFiles object returned by the detector. If no vite.config.* is present, it generates a minimal template:

// packages/cli/src/migration/migrator.ts
const viteConfig = ensureViteConfig(projectPath, configs, silent, report);

When creation is necessary, the function writes a temporary file exporting defineConfig({}). This guarantees that the subsequent merge step has a valid TypeScript source file to transform, regardless of the project's original setup.

Stage 3: The Rust-Powered Merge Process

The actual merging logic bridges JavaScript and Rust via NAPI bindings. For each discovered JSON config, mergeAndRemoveJsonConfig() (lines 59‑73) invokes the native mergeJsonConfig function.

The JavaScript Bridge

The TypeScript wrapper handles file I/O and cleanup after the Rust transformation completes:

// packages/cli/src/migration/migrator.ts
const result = mergeJsonConfig(fullViteConfigPath, fullJsonConfigPath, configKey);
if (result.updated) {
  fs.writeFileSync(fullViteConfigPath, result.content);
  fs.unlinkSync(fullJsonConfigPath);   // delete the original .ox* file
}

The mergeJsonConfig function is exposed through packages/cli/binding/src/migration.rs, acting as the NAPI bridge to the Rust core.

Core Rust Implementation

Inside crates/vite_migration/src/vite_config.rs (lines 66‑79), the merge_json_config function orchestrates the transformation:

pub fn merge_json_config(
    vite_config_path: &Path,
    json_config_path: &Path,
    config_key: &str,
) -> Result<MergeResult, Error> {
    let vite_config_content = std::fs::read_to_string(vite_config_path)?;
    let js_config = std::fs::read_to_string(json_config_path)?;
    merge_json_config_content(&vite_config_content, &js_config, config_key)
}

Before injecting the JSON content, the pipeline cleans the source data. The strip_schema_property helper (lines 16‑25) removes "$schema" annotations using regex to prevent TypeScript errors when the JSON is embedded as a JavaScript object literal. Additionally, check_function_callback (lines 31‑62) inspects the existing Vite config to detect if it uses the arrow-function form defineConfig(env => ({ ... })), storing this metadata in uses_function_callback.

AST-Grep Rule Generation

To handle diverse Vite config patterns—object literals, arrow-function returns, export default, and TypeScript satisfies expressions—the Rust code generates dynamic ast-grep transformation rules. The generate_merge_rule function (lines 165‑176) creates six distinct patterns that match common configuration styles:

fn generate_merge_rule(ts_config: &str, config_key: &str) -> String {
    let indented_config = indent_multiline(ts_config, 4);
    let template = r#"---
id: merge-json-config-object
language: TypeScript
rule:
  pattern: |
    defineConfig({
      $$$CONFIG
    })
fix: |-
  defineConfig({
    __CONFIG_KEY__: __JSON_CONFIG__,
    $$$CONFIG
  })
--- ... (other patterns) "#;

    template
        .replace("__CONFIG_KEY__", config_key)
        .replace("__JSON_CONFIG__", &indented_config)
}

The ast_grep::apply_rules function executes these generated rules against the Vite config source, returning a MergeResult containing the transformed content and an updated boolean flag.

Handling Edge Cases and Config Conflicts

The merger includes safeguards to prevent data loss and syntax errors:

  • Existing key detection: If the target Vite config already contains a lint: or fmt: key, the merge is skipped entirely via an early return in the injection logic.
  • JSONC preservation: Because the JSON file content is interpolated directly into the TypeScript source, trailing commas and // comments are preserved through the transformation and later sanitized by the TypeScript parser.
  • Function-style configs: The ast-grep rules explicitly include patterns for defineConfig(($PARAMS) => ({ ... })), ensuring the new key is correctly inserted inside the returned object literal rather than at the top level.
  • Schema stripping: The automatic removal of "$schema" properties prevents invalid TypeScript syntax when embedding strict JSON schema references into JavaScript object notation.

Summary

  • Detection: detectConfigs() in packages/cli/src/migration/detector.ts identifies .oxlintrc, .oxfmtrc, and existing vite.config.* files.
  • Preparation: ensureViteConfig() creates a minimal vite.config.ts if none exists, providing a transformation target.
  • Transformation: mergeAndRemoveJsonConfig() calls Rust NAPI functions to parse JSON, strip schema properties, generate ast-grep rules, and inject configurations under the lint or fmt keys.
  • Cleanup: Original Ox config files are deleted after successful merging, leaving a single consolidated vite.config.ts containing all migrated settings.

Frequently Asked Questions

What happens if a vite.config.ts already exists?

If vite.config.ts (or .js) is present, ensureViteConfig() skips creation and uses the existing file as the merge target. The ast-grep rules then attempt to inject the new configuration keys into the existing structure without overwriting unrelated settings.

Does Vite+ preserve comments in the original JSON config files?

Yes. Because the merger reads the raw JSON (or JSONC) file content and interpolates it directly into the TypeScript source as a string literal, existing comments and trailing commas are preserved through the transformation process. The TypeScript parser handles final validation and formatting.

How does the merger handle function-based Vite configurations?

The Rust implementation includes specific ast-grep patterns for arrow-function configurations like defineConfig((env) => ({ ... })). The transformation rule places the new lint or fmt key inside the returned object literal, ensuring compatibility with dynamic configuration functions that depend on environment variables.

What occurs if the target config already has a lint or fmt key?

The migration logic detects existing keys before attempting injection. If the target configuration already defines the key specified in configKey (e.g., lint), the merger returns early without making changes, preserving the existing configuration and leaving the original Ox config file in place.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →