Skip to content

Commit 42a571f

Browse files
committed
fix(create): preserve shorthand fmt/lint config keys
vp create and vp migrate inject default fmt/lint blocks into the project's vite.config.ts, guarded by has_config_key. That check only matched `key: value` pairs, so a template that declares fmt/lint via shorthand properties (`fmt,` / `lint,`) was treated as not having them and got a duplicate inline key. Detect shorthand_property_identifier nodes in has_config_key so a shorthand-declared key counts as present. Closes #1836
1 parent 08382aa commit 42a571f

13 files changed

Lines changed: 297 additions & 8 deletions

File tree

crates/vite_migration/src/vite_config.rs

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -291,19 +291,25 @@ fn strip_schema_property(config: &str) -> Cow<'_, str> {
291291
/// duplicate keys are resolved at runtime by JS spread semantics.
292292
///
293293
/// Returns `true` only when the key appears as a **direct** member of one of
294-
/// those recognized object literals. Comments, string occurrences, nested
295-
/// keys (e.g. `plugins: [{ fmt: ... }]`), and unrelated objects are all
296-
/// ignored correctly.
294+
/// those recognized object literals, either as a `key: value` pair or as a
295+
/// `{ key }` shorthand property (e.g. a template wiring in tooling config with
296+
/// `fmt,` / `lint,`). Comments, string occurrences, nested keys (e.g.
297+
/// `plugins: [{ fmt: ... }]`), and unrelated objects are all ignored correctly.
297298
pub fn has_config_key(vite_config_content: &str, config_key: &str) -> Result<bool, Error> {
298299
let grep = SupportLang::TypeScript.ast_grep(vite_config_content);
299300
let root = grep.root();
300301

301302
for node in root.dfs() {
302-
if node.kind() != "pair" {
303-
continue;
304-
}
305-
let Some(key_node) = node.field("key") else { continue };
306-
if !pair_key_matches(&key_node, config_key) {
303+
// Match both `key: value` pairs and `{ key }` shorthand properties. A
304+
// custom template that wires tooling config in via shorthand (`fmt,` /
305+
// `lint,`) still declares the key, so it must not get a duplicate
306+
// inline key injected by `vp create` / `vp lint --init`. See #1836.
307+
let matches_key = match node.kind().as_ref() {
308+
"pair" => node.field("key").is_some_and(|key| pair_key_matches(&key, config_key)),
309+
"shorthand_property_identifier" => node.text() == config_key,
310+
_ => continue,
311+
};
312+
if !matches_key {
307313
continue;
308314
}
309315
let Some(parent_object) = node.parent() else { continue };
@@ -1062,6 +1068,58 @@ export default () =>
10621068
assert!(has_config_key(cfg, "fmt").unwrap());
10631069
}
10641070

1071+
#[test]
1072+
fn test_has_config_key_shorthand_property() {
1073+
// A custom template that keeps tooling config in separate modules wires
1074+
// them in with shorthand properties (`fmt,` / `lint,`). The key is
1075+
// present even though there is no explicit value, so `vp create` /
1076+
// `vp lint --init` must not inject a duplicate inline key. See #1836.
1077+
let cfg = r#"import { defineConfig } from 'vite-plus';
1078+
1079+
import { fmt } from './tooling/format';
1080+
import { lint } from './tooling/lint';
1081+
1082+
export default defineConfig(({ mode }) => {
1083+
return {
1084+
server: { port: 3000 },
1085+
fmt,
1086+
lint,
1087+
};
1088+
});
1089+
"#;
1090+
assert!(has_config_key(cfg, "fmt").unwrap());
1091+
assert!(has_config_key(cfg, "lint").unwrap());
1092+
assert!(!has_config_key(cfg, "pack").unwrap());
1093+
assert!(!has_config_key(cfg, "staged").unwrap());
1094+
}
1095+
1096+
#[test]
1097+
fn test_has_config_key_shorthand_object_export() {
1098+
let cfg = r#"import { defineConfig } from 'vite-plus';
1099+
1100+
const fmt = { singleQuote: true };
1101+
1102+
export default defineConfig({
1103+
fmt,
1104+
});
1105+
"#;
1106+
assert!(has_config_key(cfg, "fmt").unwrap());
1107+
assert!(!has_config_key(cfg, "lint").unwrap());
1108+
}
1109+
1110+
#[test]
1111+
fn test_has_config_key_ignores_nested_shorthand() {
1112+
// `fmt` shorthand is nested inside a plugin's options object, not a
1113+
// top-level config key, so it must not count as present.
1114+
let cfg = r#"import { defineConfig } from 'vite-plus';
1115+
1116+
export default defineConfig({
1117+
plugins: [somePlugin({ fmt })],
1118+
});
1119+
"#;
1120+
assert!(!has_config_key(cfg, "fmt").unwrap());
1121+
}
1122+
10651123
#[test]
10661124
fn test_has_config_key_fate_template_shape() {
10671125
// Mirrors create-fate's drizzle template — the bug that motivated this fix.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"name": "local-template-monorepo-fixture",
3+
"private": true,
4+
"packageManager": "pnpm@10.0.0"
5+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
#!/usr/bin/env node
2+
// Minimal local template generator. Writes a package that declares its `fmt`
3+
// and `lint` config via shorthand properties, plus standalone Oxlint/Oxfmt
4+
// config files. `vp create` must merge-skip the standalone files instead of
5+
// injecting duplicate inline `fmt:`/`lint:` blocks into vite.config.ts (#1836).
6+
import { mkdirSync, writeFileSync } from 'node:fs';
7+
import path from 'node:path';
8+
9+
const args = process.argv.slice(2);
10+
const dirFlag = args.indexOf('--directory');
11+
const dir = dirFlag !== -1 && args[dirFlag + 1] ? args[dirFlag + 1] : 'starter-app';
12+
13+
mkdirSync(dir, { recursive: true });
14+
15+
const write = (name, content) => writeFileSync(path.join(dir, name), content);
16+
17+
write(
18+
'package.json',
19+
`${JSON.stringify({ name: path.basename(dir), version: '0.0.0', private: true }, null, 2)}\n`,
20+
);
21+
22+
write(
23+
'vite.config.ts',
24+
`import { defineConfig } from 'vite-plus';
25+
26+
import { fmt } from './tooling/format';
27+
import { lint } from './tooling/lint';
28+
29+
export default defineConfig(({ mode }) => {
30+
return {
31+
server: { port: 3000 },
32+
fmt,
33+
lint,
34+
};
35+
});
36+
`,
37+
);
38+
39+
write('.oxlintrc.json', `${JSON.stringify({ rules: {} }, null, 2)}\n`);
40+
write('.oxfmtrc.json', `${JSON.stringify({}, null, 2)}\n`);
41+
42+
mkdirSync(path.join(dir, 'tooling'), { recursive: true });
43+
writeFileSync(path.join(dir, 'tooling', 'format.ts'), 'export const fmt = { ignorePatterns: [] };\n');
44+
writeFileSync(path.join(dir, 'tooling', 'lint.ts'), 'export const lint = { rules: {} };\n');
45+
46+
console.log(`cloned starter-template to ${dir}`);
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"name": "starter-template",
3+
"version": "0.0.0",
4+
"private": true,
5+
"description": "A local starter template that wires fmt/lint via shorthand.",
6+
"type": "module",
7+
"bin": "./bin/index.mjs"
8+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
packages:
2+
- packages/*
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
> vp create starter --no-interactive --no-agent -- --directory my-app # run the local create.templates entry; generated pkg declares fmt/lint via shorthand
2+
3+
Generating project…
4+
5+
Running: node <cwd>/packages/starter-template/bin/index.mjs --directory my-app
6+
cloned starter-template to my-app
7+
8+
Monorepo integration...
9+
10+
lint config already present in packages/my-app/vite.config.ts — removed redundant packages/my-app/.oxlintrc.json
11+
12+
fmt config already present in packages/my-app/vite.config.ts — removed redundant packages/my-app/.oxfmtrc.json
13+
14+
Formatting code...
15+
16+
Code formatted
17+
◇ Scaffolded packages/my-app
18+
• Node <semver> pnpm <semver>
19+
→ Next: cd packages/my-app && vp run
20+
21+
> cat packages/my-app/vite.config.ts # fmt/lint stay shorthand only, no injected duplicate inline fmt:/lint: blocks (#1836)
22+
import { defineConfig } from "vite-plus";
23+
24+
import { fmt } from "./tooling/format";
25+
import { lint } from "./tooling/lint";
26+
27+
export default defineConfig(({ mode }) => {
28+
return {
29+
server: { port: 3000 },
30+
fmt,
31+
lint,
32+
};
33+
});
34+
35+
> test ! -f packages/my-app/.oxlintrc.json # standalone lint config merge-skipped and removed
36+
> test ! -f packages/my-app/.oxfmtrc.json # standalone fmt config merge-skipped and removed
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"commands": [
3+
"vp create starter --no-interactive --no-agent -- --directory my-app # run the local create.templates entry; generated pkg declares fmt/lint via shorthand",
4+
"cat packages/my-app/vite.config.ts # fmt/lint stay shorthand only, no injected duplicate inline fmt:/lint: blocks (#1836)",
5+
"test ! -f packages/my-app/.oxlintrc.json # standalone lint config merge-skipped and removed",
6+
"test ! -f packages/my-app/.oxfmtrc.json # standalone fmt config merge-skipped and removed"
7+
]
8+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { defineConfig } from 'vite-plus';
2+
3+
export default defineConfig({
4+
create: {
5+
templates: [
6+
{
7+
name: 'starter',
8+
description: 'A local starter template that wires fmt/lint via shorthand.',
9+
template: './packages/starter-template',
10+
},
11+
],
12+
},
13+
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"name": "migration-inline-config-shorthand",
3+
"version": "0.0.0",
4+
"private": true,
5+
"devDependencies": {
6+
"oxfmt": "1",
7+
"oxlint": "1"
8+
}
9+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
> vp migrate --no-interactive --no-hooks 2>&1 # must NOT duplicate fmt/lint already declared as shorthand properties (#1836)
2+
◇ Migrated . to Vite+
3+
• Node <semver> pnpm <semver>
4+
5+
> cat vite.config.ts # fmt/lint stay as shorthand only, no injected inline fmt:/lint: blocks
6+
import { defineConfig } from 'vite-plus';
7+
8+
// Mirrors a custom template that keeps tooling config in separate modules and
9+
// wires them in with shorthand properties (`fmt,` / `lint,`). See #1836.
10+
const fmt = { ignorePatterns: [] };
11+
const lint = { rules: {} };
12+
13+
export default defineConfig(({ mode }) => {
14+
return {
15+
server: { port: 3000 },
16+
fmt,
17+
lint,
18+
};
19+
});
20+
21+
> test ! -f .oxlintrc.json # no standalone lint config generated
22+
> test ! -f .oxfmtrc.json # no standalone fmt config generated

0 commit comments

Comments
 (0)