diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_entry/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_entry/package.json new file mode 100644 index 0000000000..7c38eba20f --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_entry/package.json @@ -0,0 +1,15 @@ +{ + "name": "config-entry", + "private": true, + "type": "module", + "devDependencies": { + "@types/node": "24.10.3", + "typescript": "7.0.2", + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vite-plus": "latest" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest" + }, + "packageManager": "npm@11.11.0" +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_entry/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_entry/snapshots.toml new file mode 100644 index 0000000000..d04007a3d6 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_entry/snapshots.toml @@ -0,0 +1,23 @@ +[[case]] +name = "config_entry_without_pack" +vp = "global" +local-registry = true +comment = "The published app config entry keeps native task loading and strict TypeScript 7 configuration without optional pack integrations." +steps = [ + { argv = ["vp", "install", "--ignore-scripts"], timeout = 120000, snapshot = false }, + ["vp", "run", "verify"], +] + +[[case]] +name = "config_entry_with_pack" +vp = "global" +local-registry = true +comment = "Both the original entry and explicit pack type activation preserve real injected module types and references." +steps = [ + { argv = ["vpt", "json-edit", "package.json", "devDependencies.typescript", "6.0.3"], snapshot = false }, + { argv = ["vpt", "json-edit", "package.json", "devDependencies.@arethetypeswrong/core", "0.18.5"], snapshot = false }, + { argv = ["vpt", "json-edit", "package.json", "devDependencies.@volar/typescript", "2.4.28"], snapshot = false }, + { argv = ["vpt", "json-edit", "package.json", "devDependencies.@volar/language-service", "2.4.28"], snapshot = false }, + { argv = ["vp", "install", "--ignore-scripts"], timeout = 120000, snapshot = false }, + ["node", "verify.mjs", "pack"], +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_entry/snapshots/config_entry_with_pack.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_entry/snapshots/config_entry_with_pack.md new file mode 100644 index 0000000000..22b3bd7bb2 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_entry/snapshots/config_entry_with_pack.md @@ -0,0 +1,24 @@ +# config_entry_with_pack + +Both the original entry and explicit pack type activation preserve real injected module types and references. + +## `vpt json-edit package.json devDependencies.typescript 6.0.3` + + +## `vpt json-edit package.json devDependencies.@arethetypeswrong/core 0.18.5` + + +## `vpt json-edit package.json devDependencies.@volar/typescript 2.4.28` + + +## `vpt json-edit package.json devDependencies.@volar/language-service 2.4.28` + + +## `vp install --ignore-scripts` + + +## `node verify.mjs pack` + +``` +Original and explicit pack configuration preserve injected module types and identity +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_entry/snapshots/config_entry_without_pack.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_entry/snapshots/config_entry_without_pack.md new file mode 100644 index 0000000000..cadb5bcb35 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_entry/snapshots/config_entry_without_pack.md @@ -0,0 +1,15 @@ +# config_entry_without_pack + +The published app config entry keeps native task loading and strict TypeScript 7 configuration without optional pack integrations. + +## `vp install --ignore-scripts` + + +## `vp run verify` + +``` +VITE+ - The Unified Toolchain for the Web + +$ node verify.mjs +App configuration is strict without pack; ESM, CommonJS, and native plugins retain identity +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_entry/verify.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_entry/verify.mjs new file mode 100644 index 0000000000..fd2d211c03 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_entry/verify.mjs @@ -0,0 +1,120 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); + +function typecheck(source) { + writeFileSync('fixture.ts', source); + const result = spawnSync( + process.execPath, + [ + 'node_modules/typescript/bin/tsc', + '--noEmit', + '--strict', + '--pretty', + 'false', + '--skipLibCheck', + 'false', + '--moduleResolution', + 'bundler', + '--module', + 'esnext', + '--target', + 'es2022', + '--types', + 'node', + 'fixture.ts', + ], + { encoding: 'utf8' }, + ); + assert.equal(result.error, undefined); + assert.equal(result.stderr, ''); + return result; +} + +if (process.argv[2] === 'pack') { + const integration = `import * as attw from '@arethetypeswrong/core'; +import * as volarTypeScript from '@volar/typescript'; +const pack = { + entry: 'src/index.ts', + attw: { module: attw }, + dts: { customLanguages: [{ + extensionPatterns: [/\\.vue$/], volarTypeScript, createVolarPlugins: () => [], + }] }, +}; +export default defineConfig({ pack });`; + for (const imports of [ + "import { defineConfig } from 'vite-plus';", + "import { defineConfig } from 'vite-plus/config'; import type {} from 'vite-plus/pack';", + ]) { + const result = typecheck(`${imports}\n${integration}`); + assert.equal(result.status, 0, result.stdout); + } + const invalid = typecheck(`import { defineConfig } from 'vite-plus/config'; +import type { PackUserConfig } from 'vite-plus/pack'; +const pack: PackUserConfig = { entry: 42 }; +export default defineConfig({ pack });`); + assert.notEqual(invalid.status, 0); + assert.match(invalid.stdout, /Type 'number' is not assignable/); + assert.doesNotMatch(invalid.stdout, /node_modules|Cannot find module/); + + const { defineConfig } = await import('vite-plus'); + const attw = await import('@arethetypeswrong/core'); + const volarTypeScript = await import('@volar/typescript'); + const pack = { attw: { module: attw }, dts: { customLanguages: [{ volarTypeScript }] } }; + const result = defineConfig({ pack }); + assert.equal(result.pack, pack); + assert.equal(result.pack.attw.module, attw); + assert.equal(result.pack.dts.customLanguages[0].volarTypeScript, volarTypeScript); + console.log( + 'Original and explicit pack configuration preserve injected module types and identity', + ); +} else { + for (const name of [ + '@arethetypeswrong/core', + '@vitejs/devtools/cli-commands', + 'publint', + 'unplugin-unused', + '@volar/typescript', + ]) { + assert.throws(() => require.resolve(name), { code: 'MODULE_NOT_FOUND' }); + } + const result = typecheck(readFileSync('vite.config.ts', 'utf8')); + assert.equal(result.status, 0, result.stdout); + + for (const [config, message] of [ + ["pack: { entry: 'src/index.ts' }", /'pack' does not exist/], + ["fmt: { semi: 'yes' }", /not assignable to type 'boolean/], + ]) { + const invalid = typecheck(`import { defineConfig } from 'vite-plus/config'; +export default defineConfig({ ${config} });`); + assert.notEqual(invalid.status, 0); + assert.match(invalid.stdout, message); + assert.doesNotMatch(invalid.stdout, /node_modules|Cannot find module/); + } + + const root = await import('vite-plus'); + const config = await import('vite-plus/config'); + assert.deepEqual(Object.keys(config), Object.keys(root)); + for (const name of Object.keys(root)) { + assert.equal(config[name], root[name], name); + } + assert.equal(require('vite-plus/config'), require('vite-plus')); + const plugin = { name: 'consumer' }; + const configured = config.defineConfig({ plugins: [plugin] }); + assert.deepEqual( + configured.plugins.map(({ name }) => name), + [ + 'vite-plus:vitest-resolver', + 'vite-plus:auto-inline-matcher-deps', + 'vite-plus:coverage-version-guard', + 'consumer', + ], + ); + assert.equal(configured.plugins.at(-1), plugin); + console.log( + 'App configuration is strict without pack; ESM, CommonJS, and native plugins retain identity', + ); +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_entry/vite.config.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_entry/vite.config.ts new file mode 100644 index 0000000000..dae5ea82eb --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_entry/vite.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite-plus/config'; + +export default defineConfig({ + build: { sourcemap: true }, + fmt: { semi: true }, + lint: { rules: { 'no-debugger': 'error' } }, + test: { include: ['tests/**/*.test.ts'] }, + run: { tasks: { verify: { command: 'node verify.mjs' } } }, +}); + +throw new Error('Static tasks must not evaluate the Vite config.'); diff --git a/crates/vp_migration/src/vite_config.rs b/crates/vp_migration/src/vite_config.rs index 55adc106af..b84b6180e6 100644 --- a/crates/vp_migration/src/vite_config.rs +++ b/crates/vp_migration/src/vite_config.rs @@ -110,6 +110,8 @@ fn merge_json_config_content( // Apply the transformation let (content, updated) = ast_grep::apply_rules(vite_config_content, &rule_yaml)?; + let content = + if updated && config_key == "pack" { enable_pack_config_types(content) } else { content }; Ok(MergeResult { content, updated, uses_function_callback }) } @@ -555,7 +557,7 @@ fn has_conflicting_lazy_plugins_binding(content: &str) -> bool { continue; } let text = node.text(); - if imports_from_vite_plus(&text) { + if vite_plus_import_source(&node).is_some() { continue; } if import_binds_lazy_plugins(&text) { @@ -679,6 +681,7 @@ fn ensure_lazy_plugins_import(content: &str) -> String { let root = grep.root(); let mut import_insert_at = None; let mut value_import_replacement = None; + let mut import_source = "vite-plus"; for node in root.dfs() { if node.kind() != "import_statement" { @@ -688,7 +691,11 @@ fn ensure_lazy_plugins_import(content: &str) -> String { Some(import_insert_at.map_or(node.range().end, |end: usize| end.max(node.range().end))); let text = node.text(); - if !imports_from_vite_plus(&text) || text.trim_start().starts_with("import type") { + let Some(source) = vite_plus_import_source(&node) else { continue }; + if source == "vite-plus/config" { + import_source = "vite-plus/config"; + } + if text.trim_start().starts_with("import type") { continue; } let Some(open_brace) = text.find('{') else { continue }; @@ -725,7 +732,7 @@ fn ensure_lazy_plugins_import(content: &str) -> String { return updated; } - let import_stmt = "import { lazyPlugins } from 'vite-plus';"; + let import_stmt = format!("import {{ lazyPlugins }} from '{import_source}';"); if let Some(insert_at) = import_insert_at { let mut updated = content.to_owned(); updated.insert_str(insert_at, &format!("\n{import_stmt}")); @@ -735,8 +742,34 @@ fn ensure_lazy_plugins_import(content: &str) -> String { } } -fn imports_from_vite_plus(import_statement: &str) -> bool { - import_statement.contains("from 'vite-plus'") || import_statement.contains("from \"vite-plus\"") +fn vite_plus_import_source(node: &Node<'_, D>) -> Option<&'static str> { + match node.field("source")?.text().as_ref() { + "'vite-plus'" | "\"vite-plus\"" => Some("vite-plus"), + "'vite-plus/config'" | "\"vite-plus/config\"" => Some("vite-plus/config"), + _ => None, + } +} + +fn enable_pack_config_types(mut content: String) -> String { + let grep = SupportLang::TypeScript.ast_grep(&content); + let mut edits = grep + .root() + .dfs() + .filter_map(|node| { + if node.kind() != "import_statement" + || vite_plus_import_source(&node) != Some("vite-plus/config") + { + return None; + } + let source = node.field("source")?; + Some((source.range(), source.text().replace("vite-plus/config", "vite-plus"))) + }) + .collect::>(); + edits.sort_by_key(|(range, _)| std::cmp::Reverse(range.start)); + for (range, replacement) in edits { + content.replace_range(range, &replacement); + } + content } fn has_lazy_plugins_specifier(specifiers: &str) -> bool { @@ -988,7 +1021,9 @@ fn merge_tsdown_config_content( // Step 2: Add pack: tsdownConfig to defineConfig let pack_rule = generate_merge_rule("tsdownConfig", "pack"); - let (final_content, _) = ast_grep::apply_rules(&content_with_import, &pack_rule)?; + let (final_content, pack_added) = ast_grep::apply_rules(&content_with_import, &pack_rule)?; + let final_content = + if pack_added { enable_pack_config_types(final_content) } else { final_content }; Ok(MergeResult { content: final_content, updated: true, uses_function_callback }) } @@ -2212,6 +2247,26 @@ export default { ); } + #[test] + fn test_wrap_lazy_plugins_preserves_config_entry() { + for import in [ + "import { defineConfig } from 'vite-plus/config';", + "import { defineConfig } from\n'vite-plus/config';", + "import { defineConfig, lazyPlugins } from 'vite-plus/config';", + "import type { UserConfig } from 'vite-plus/config';", + "import { defineConfig /* keep */ } from 'vite-plus/config';", + ] { + let content = + format!("{import}\nexport default defineConfig({{ plugins: [react()] }});"); + let result = wrap_lazy_plugins_content(&content, None).unwrap(); + assert!(result.updated); + assert!(result.content.contains("plugins: lazyPlugins(() => [react()])")); + assert!(!result.content.contains("from 'vite-plus'")); + let second = wrap_lazy_plugins_content(&result.content, None).unwrap(); + assert!(!second.updated); + } + } + #[test] fn test_wrap_lazy_plugins_handles_multiline_imports() { let vite_config = r#"import { @@ -2375,6 +2430,25 @@ export default defineConfig({ assert_eq!(second.content, first.content); } + #[test] + fn test_merging_pack_enables_pack_configuration_types() { + let config = r#"import { defineConfig } from 'vite-plus/config'; +import type { UserConfig } from 'vite-plus/config'; +export type Config = UserConfig; +const label = 'vite-plus/config'; +export default defineConfig({});"#; + for result in [ + merge_json_config_content(config, "{ entry: 'src/index.ts' }", "pack").unwrap(), + merge_tsdown_config_content(config, "./tsdown.config.ts").unwrap(), + ] { + assert!(result.updated); + assert!(result.content.contains("pack:")); + assert!(result.content.contains("import { defineConfig } from 'vite-plus';")); + assert!(result.content.contains("import type { UserConfig } from 'vite-plus';")); + assert!(result.content.contains("const label = 'vite-plus/config';")); + } + } + #[test] fn test_merge_tsdown_config_content_simple() { let vite_config = r#"import { defineConfig } from 'vite-plus'; diff --git a/crates/vp_static_config/src/lib.rs b/crates/vp_static_config/src/lib.rs index 2af9103163..59c82a9db9 100644 --- a/crates/vp_static_config/src/lib.rs +++ b/crates/vp_static_config/src/lib.rs @@ -15,7 +15,7 @@ use rustc_hash::FxHashMap; use vt_path::AbsolutePath; /// Packages whose `defineConfig` helpers preserve top-level config fields. -const TRUSTED_DEFINE_CONFIG_PACKAGES: &[&str] = &["vite-plus", "vite"]; +const TRUSTED_DEFINE_CONFIG_PACKAGES: &[&str] = &["vite-plus", "vite-plus/config", "vite"]; /// The name of the config helper static extraction trusts. const DEFINE_CONFIG: &str = "defineConfig"; @@ -746,6 +746,18 @@ mod tests { assert!(result.get("run").is_none()); } + #[test] + fn define_config_from_config_entry_is_static() { + for source in [ + "import { defineConfig } from 'vite-plus/config'; export default defineConfig({ run: { cacheScripts: true } });", + "const { defineConfig } = require('vite-plus/config'); module.exports = defineConfig({ run: { cacheScripts: true } });", + "const defineConfig = require('vite-plus/config').defineConfig; module.exports = defineConfig({ run: { cacheScripts: true } });", + ] { + let result = parse_js_ts_config(source, "js"); + assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); + } + } + #[test] fn define_config_import_from_other_package_is_non_static() { let result = parse( diff --git a/docs/config/index.md b/docs/config/index.md index bfab9ef6c5..25a58177cd 100644 --- a/docs/config/index.md +++ b/docs/config/index.md @@ -21,6 +21,26 @@ export default defineConfig({ }); ``` +## Application configuration without pack + +If your project does not use `vp pack`, import from `vite-plus/config` to type-check its configuration without loading declarations for optional pack integrations: + +```ts [vite.config.ts] +import { defineConfig } from 'vite-plus/config'; + +export default defineConfig({ + build: {}, + run: {}, + fmt: {}, + lint: {}, + test: {}, +}); +``` + +This entry exports the same runtime helpers and Vite APIs as `vite-plus`. It includes all Vite+ configuration fields except `pack`, which TypeScript rejects unless you also import types from `vite-plus/pack`. The original `vite-plus` entry continues to include pack configuration. + +TypeScript module augmentation applies to the entire program: importing `vite-plus` or `vite-plus/pack` anywhere in the same TypeScript project enables pack configuration and loads its integration declarations. + ## Vite+ Specific Configuration Vite+ extends the basic Vite configuration with these additions: diff --git a/packages/cli/package.json b/packages/cli/package.json index 54a0760e1d..7d55bbc40c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -37,6 +37,11 @@ "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { + "./config": { + "types": "./dist/config.d.ts", + "import": "./dist/config.js", + "require": "./dist/index.cjs" + }, ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts new file mode 100644 index 0000000000..2c4b0237f7 --- /dev/null +++ b/packages/cli/src/config.ts @@ -0,0 +1,29 @@ +import { defineConfig, defineProject, lazyPlugins } from './define-config.ts'; + +export * from 'vite'; + +export { + configDefaults, + coverageConfigDefaults, + defaultBrowserPort, + defaultExclude, + defaultInclude, +} from 'vitest/config'; + +export type { + TestProjectConfiguration, + TestProjectInlineConfiguration, + TestTagDefinition, + TestUserConfig, + UserProjectConfigExport, + UserProjectConfigFn, + UserWorkspaceConfig, + ViteUserConfig, + ViteUserConfigExport, + ViteUserConfigFn, + ViteUserConfigFnObject, + ViteUserConfigFnPromise, + WatcherTriggerPattern, +} from 'vitest/config'; + +export { defineConfig, defineProject, lazyPlugins }; diff --git a/packages/cli/src/define-config.ts b/packages/cli/src/define-config.ts index 0e78fb0347..a39173ed86 100644 --- a/packages/cli/src/define-config.ts +++ b/packages/cli/src/define-config.ts @@ -17,7 +17,6 @@ import { import type { InlineConfig as VitestInlineConfig } from 'vitest/node'; import type { CreateTemplateEntry } from './create/org-manifest.ts'; -import type { PackUserConfig } from './pack.ts'; import type { RunConfig } from './run-config.ts'; import type { StagedConfig } from './staged-config.ts'; import { CONFIG_METADATA_ENV, VITEST_VERSION } from './utils/constants.ts'; @@ -51,8 +50,6 @@ declare module 'vite' { lint?: boolean; }; - pack?: PackUserConfig | PackUserConfig[]; - /** * Default target directory for `vp dev` / `build` / `preview` / `pack` * when invoked bare in the directory containing this config (an implicit diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 2c4b0237f7..076924ce8a 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,29 +1,2 @@ -import { defineConfig, defineProject, lazyPlugins } from './define-config.ts'; - -export * from 'vite'; - -export { - configDefaults, - coverageConfigDefaults, - defaultBrowserPort, - defaultExclude, - defaultInclude, -} from 'vitest/config'; - -export type { - TestProjectConfiguration, - TestProjectInlineConfiguration, - TestTagDefinition, - TestUserConfig, - UserProjectConfigExport, - UserProjectConfigFn, - UserWorkspaceConfig, - ViteUserConfig, - ViteUserConfigExport, - ViteUserConfigFn, - ViteUserConfigFnObject, - ViteUserConfigFnPromise, - WatcherTriggerPattern, -} from 'vitest/config'; - -export { defineConfig, defineProject, lazyPlugins }; +export type { PackUserConfig } from './pack.ts'; +export * from './config.ts'; diff --git a/packages/cli/src/pack.ts b/packages/cli/src/pack.ts index 4b7f96e0ca..3b5eacebbb 100644 --- a/packages/cli/src/pack.ts +++ b/packages/cli/src/pack.ts @@ -2,6 +2,12 @@ import type { UserConfig as TsdownUserConfig } from 'vite/pack'; export * from 'vite/pack'; +declare module 'vite' { + interface UserConfig { + pack?: PackUserConfig | PackUserConfig[]; + } +} + export interface PackUserConfig extends TsdownUserConfig { /** * When loading env variables from `envFile`, only include variables with these prefixes. diff --git a/packages/cli/tsdown.config.ts b/packages/cli/tsdown.config.ts index f68e7ad32e..340c95c9d0 100644 --- a/packages/cli/tsdown.config.ts +++ b/packages/cli/tsdown.config.ts @@ -46,6 +46,7 @@ export default defineConfig([ entry: { bin: './src/bin.ts', index: './src/index.ts', + config: './src/config.ts', 'define-config': './src/define-config.ts', fmt: './src/fmt.ts', lint: './src/lint.ts',