vize 0.303.0 → 0.310.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,221 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import {
5
+ dependencyNames,
6
+ OXLINT_CONFIG_FILES,
7
+ parsePackageJson,
8
+ readRequiredFile,
9
+ VIZE_CONFIG_FILES,
10
+ } from "../setup/config.js";
11
+
12
+ export const NUXT_CONFIG_FILES = [
13
+ "nuxt.config.ts",
14
+ "nuxt.config.mts",
15
+ "nuxt.config.js",
16
+ "nuxt.config.mjs",
17
+ ] as const;
18
+
19
+ export const VITE_CONFIG_FILES = [
20
+ "vite.config.ts",
21
+ "vite.config.mts",
22
+ "vite.config.js",
23
+ "vite.config.mjs",
24
+ ] as const;
25
+
26
+ export type PackageManager = "pnpm" | "npm" | "yarn" | "bun";
27
+
28
+ /**
29
+ * Bundler integration `init` can wire up.
30
+ *
31
+ * Nuxt outranks Vite because a Nuxt project owns its own Vite instance: adding
32
+ * `@vizejs/vite-plugin` to a Nuxt-managed Vite config would fight the module.
33
+ */
34
+ export type Framework = "nuxt" | "vite" | "none";
35
+
36
+ export interface ProjectDetection {
37
+ readonly root: string;
38
+ readonly packageManager: PackageManager | null;
39
+ readonly framework: Framework;
40
+ readonly nuxtConfig: string | null;
41
+ readonly viteConfigs: readonly string[];
42
+ readonly usesVitePlus: boolean;
43
+ readonly typescript: boolean;
44
+ readonly tsconfig: string | null;
45
+ readonly vizeConfig: string | null;
46
+ readonly oxlintConfig: string | null;
47
+ readonly hasVitePlusLintBlock: boolean;
48
+ readonly hasVizeVitePlugin: boolean;
49
+ readonly hasVizeNuxtModule: boolean;
50
+ readonly dependencies: ReadonlySet<string>;
51
+ readonly scripts: Readonly<Record<string, string>>;
52
+ readonly vscodeRecommendsVize: boolean;
53
+ }
54
+
55
+ /**
56
+ * Package-manager detection.
57
+ *
58
+ * Deliberately mirrors `detect_package_manager` in
59
+ * `crates/vize_canon/src/batch/error.rs`, including the lockfile priority order
60
+ * and the `packageManager` prefix fallback. The Rust side suggests an install
61
+ * command in its corsa-not-found message; if the two ever disagreed, a user
62
+ * would be told to run `pnpm add` by one half of the toolchain and `npm install`
63
+ * by the other.
64
+ */
65
+ export function detectPackageManager(root: string): PackageManager | null {
66
+ const exists = (name: string): boolean => fs.existsSync(path.join(root, name));
67
+ if (exists("pnpm-lock.yaml")) {
68
+ return "pnpm";
69
+ }
70
+ if (exists("bun.lockb") || exists("bun.lock")) {
71
+ return "bun";
72
+ }
73
+ if (exists("yarn.lock")) {
74
+ return "yarn";
75
+ }
76
+ if (exists("package-lock.json")) {
77
+ return "npm";
78
+ }
79
+ return detectPackageManagerField(root);
80
+ }
81
+
82
+ function detectPackageManagerField(root: string): PackageManager | null {
83
+ let source: string;
84
+ try {
85
+ source = fs.readFileSync(path.join(root, "package.json"), "utf8");
86
+ } catch {
87
+ return null;
88
+ }
89
+ let field: unknown;
90
+ try {
91
+ field = (JSON.parse(source) as { packageManager?: unknown }).packageManager;
92
+ } catch {
93
+ return null;
94
+ }
95
+ if (typeof field !== "string") {
96
+ return null;
97
+ }
98
+ for (const candidate of ["pnpm", "yarn", "bun", "npm"] as const) {
99
+ if (field.startsWith(candidate)) {
100
+ return candidate;
101
+ }
102
+ }
103
+ return null;
104
+ }
105
+
106
+ /**
107
+ * Applies an explicit `--vite` / `--nuxt` choice over what detection concluded.
108
+ *
109
+ * Overriding the framework rather than branching later keeps one code path: the
110
+ * planner, the prompt and the printed detection summary all see the same answer,
111
+ * so the summary cannot claim Vite while the plan configures Nuxt.
112
+ */
113
+ export function withFramework(
114
+ detection: ProjectDetection,
115
+ framework: Framework | null,
116
+ ): ProjectDetection {
117
+ return framework === null || framework === detection.framework
118
+ ? detection
119
+ : { ...detection, framework };
120
+ }
121
+
122
+ export function detectProject(root: string): ProjectDetection {
123
+ const packagePath = path.join(root, "package.json");
124
+ const packageSource = readRequiredFile(packagePath, "No package.json found");
125
+ const packageJson = parsePackageJson(packagePath, packageSource);
126
+ const dependencies = dependencyNames(packageJson);
127
+ const scripts = readScripts(packageJson);
128
+
129
+ const nuxtConfig = findExisting(root, NUXT_CONFIG_FILES);
130
+ const viteConfigs = VITE_CONFIG_FILES.filter((candidate) =>
131
+ fs.existsSync(path.join(root, candidate)),
132
+ );
133
+ const viteSource = viteConfigs.length === 1 ? readFile(root, viteConfigs[0]!) : null;
134
+ const nuxtSource = nuxtConfig === null ? null : readFile(root, nuxtConfig);
135
+
136
+ return {
137
+ root,
138
+ packageManager: detectPackageManager(root),
139
+ framework: detectFramework(nuxtConfig, viteConfigs, dependencies),
140
+ nuxtConfig,
141
+ viteConfigs,
142
+ usesVitePlus: detectVitePlus(dependencies, viteSource, scripts),
143
+ typescript: dependencies.has("typescript") || fs.existsSync(path.join(root, "tsconfig.json")),
144
+ tsconfig: fs.existsSync(path.join(root, "tsconfig.json")) ? "tsconfig.json" : null,
145
+ vizeConfig: findExisting(root, VIZE_CONFIG_FILES),
146
+ oxlintConfig: findExisting(root, OXLINT_CONFIG_FILES),
147
+ hasVitePlusLintBlock: viteSource !== null && viteSource.includes("oxlint-plugin-vize"),
148
+ hasVizeVitePlugin: viteSource !== null && viteSource.includes("@vizejs/vite-plugin"),
149
+ hasVizeNuxtModule: nuxtSource !== null && nuxtSource.includes("@vizejs/nuxt"),
150
+ dependencies,
151
+ scripts,
152
+ vscodeRecommendsVize: detectVscodeRecommendation(root),
153
+ };
154
+ }
155
+
156
+ function detectFramework(
157
+ nuxtConfig: string | null,
158
+ viteConfigs: readonly string[],
159
+ dependencies: ReadonlySet<string>,
160
+ ): Framework {
161
+ if (nuxtConfig !== null || dependencies.has("nuxt")) {
162
+ return "nuxt";
163
+ }
164
+ return viteConfigs.length > 0 ? "vite" : "none";
165
+ }
166
+
167
+ /**
168
+ * Whether the project's lint command is `vp lint` rather than the `oxlint` binary.
169
+ *
170
+ * This single boolean decides which file `init` must write the Oxlint
171
+ * configuration into, so it is deliberately generous: a project is treated as a
172
+ * Vite+ project if the dependency is declared, if its Vite config imports from
173
+ * `vite-plus`, or if any script invokes `vp`. Guessing "plain Oxlint" for a
174
+ * Vite+ project is the failure that #3389 documented — `vp lint` would ignore
175
+ * `.oxlintrc.json` and report zero Vize diagnostics while exiting 0.
176
+ */
177
+ function detectVitePlus(
178
+ dependencies: ReadonlySet<string>,
179
+ viteSource: string | null,
180
+ scripts: Readonly<Record<string, string>>,
181
+ ): boolean {
182
+ if (dependencies.has("vite-plus")) {
183
+ return true;
184
+ }
185
+ if (viteSource !== null && /from\s+["']vite-plus["']/u.test(viteSource)) {
186
+ return true;
187
+ }
188
+ return Object.values(scripts).some((command) => /(?:^|[\s&|;])vpx?(?:\s|$)/u.test(command));
189
+ }
190
+
191
+ function detectVscodeRecommendation(root: string): boolean {
192
+ let source: string;
193
+ try {
194
+ source = fs.readFileSync(path.join(root, ".vscode", "extensions.json"), "utf8");
195
+ } catch {
196
+ return false;
197
+ }
198
+ return source.includes("ubugeeei.vize");
199
+ }
200
+
201
+ function readScripts(packageJson: Record<string, unknown>): Record<string, string> {
202
+ const scripts = packageJson.scripts;
203
+ if (typeof scripts !== "object" || scripts === null || Array.isArray(scripts)) {
204
+ return {};
205
+ }
206
+ const entries: Record<string, string> = {};
207
+ for (const [name, command] of Object.entries(scripts)) {
208
+ if (typeof command === "string") {
209
+ entries[name] = command;
210
+ }
211
+ }
212
+ return entries;
213
+ }
214
+
215
+ function findExisting(root: string, candidates: readonly string[]): string | null {
216
+ return candidates.find((candidate) => fs.existsSync(path.join(root, candidate))) ?? null;
217
+ }
218
+
219
+ function readFile(root: string, relative: string): string {
220
+ return fs.readFileSync(path.join(root, relative), "utf8");
221
+ }
@@ -0,0 +1,161 @@
1
+ import { VITE_LINT_BLOCK, VITE_LINT_IMPORT, VITE_PLUGIN_IMPORT } from "./templates.js";
2
+ import { countConfigCalls, findTopLevelKey, readTopLevelArray } from "./top-level.js";
3
+
4
+ /**
5
+ * Conservative source edits for user-owned `vite.config.*` and `nuxt.config.*`.
6
+ *
7
+ * Every function here returns `null` rather than guessing. A wrong edit to a
8
+ * build config breaks the project; a `null` costs the user one paste of a
9
+ * snippet `init` prints for them.
10
+ */
11
+
12
+ const VITE_CALLEE = "defineConfig";
13
+ const NUXT_CALLEE = "defineNuxtConfig";
14
+
15
+ /**
16
+ * `defineConfig({` plus the newline that usually follows it.
17
+ *
18
+ * The trailing newline is consumed and re-emitted by the injectors so an
19
+ * inserted key does not leave a stray blank line behind in the user's file.
20
+ */
21
+ const VITE_OPENING = /\bdefineConfig\s*\(\s*\{[^\S\r\n]*(?:\r?\n)?/u;
22
+ const NUXT_OPENING = /\bdefineNuxtConfig\s*\(\s*\{[^\S\r\n]*(?:\r?\n)?/u;
23
+
24
+ /**
25
+ * Whether a Vite config is a single plain `defineConfig({ ... })` call that a
26
+ * new top-level key can be inserted into.
27
+ *
28
+ * Anything else -- several `defineConfig` calls, a config built from a variable,
29
+ * or a config that already declares the key -- is left alone.
30
+ */
31
+ export function canInjectViteKey(source: string, key: string): boolean {
32
+ if (hasTopLevelKey(source, key)) {
33
+ return false;
34
+ }
35
+ return countConfigCalls(source, VITE_CALLEE) === 1;
36
+ }
37
+
38
+ /** Whether the Vite config declares `key` at the top level of its `defineConfig` call. */
39
+ export function hasTopLevelKey(source: string, key: string): boolean {
40
+ return findTopLevelKey(source, VITE_CALLEE, key) !== null;
41
+ }
42
+
43
+ /** Whether the Vite+ `lint` block can be injected into this source. */
44
+ export function canInjectViteLint(source: string): boolean {
45
+ if (source.includes("oxlint-plugin-vize")) {
46
+ return false;
47
+ }
48
+ return canInjectViteKey(source, "lint");
49
+ }
50
+
51
+ /**
52
+ * Inserts the `lint` block, and its import, into a Vite config.
53
+ *
54
+ * Returns `null` when the source does not have the shape `canInjectViteLint`
55
+ * accepts, so callers cannot inject blindly.
56
+ */
57
+ export function injectViteLint(source: string): string | null {
58
+ if (!canInjectViteLint(source)) {
59
+ return null;
60
+ }
61
+ const withImport = insertImport(source, VITE_LINT_IMPORT);
62
+ if (withImport === null) {
63
+ return null;
64
+ }
65
+ return withImport.replace(VITE_OPENING, () => `defineConfig({\n${VITE_LINT_BLOCK}`);
66
+ }
67
+
68
+ /**
69
+ * Adds `vize()` to a Vite config's top-level `plugins` array, importing the
70
+ * plugin.
71
+ *
72
+ * The array is located by depth-aware scan rather than by regex: a Vite+ config
73
+ * can carry a second `plugins` key inside its `lint` block, and appending Vize's
74
+ * Vite plugin to Oxlint's plugin list would corrupt both.
75
+ */
76
+ export function injectVitePlugin(source: string): string | null {
77
+ if (source.includes("@vizejs/vite-plugin")) {
78
+ return null;
79
+ }
80
+ const withImport = insertImport(source, VITE_PLUGIN_IMPORT);
81
+ if (withImport === null) {
82
+ return null;
83
+ }
84
+ const plugins = readTopLevelArray(withImport, VITE_CALLEE, "plugins");
85
+ if (plugins !== null) {
86
+ return insertArrayEntry(withImport, plugins.contentStart, "vize()", plugins.empty);
87
+ }
88
+ if (findTopLevelKey(withImport, VITE_CALLEE, "plugins") !== null) {
89
+ // `plugins` exists but is not an array literal; inserting would change what
90
+ // the config evaluates to.
91
+ return null;
92
+ }
93
+ if (!canInjectViteKey(withImport, "plugins")) {
94
+ return null;
95
+ }
96
+ return withImport.replace(VITE_OPENING, () => `defineConfig({\n plugins: [vize()],\n`);
97
+ }
98
+
99
+ /**
100
+ * Adds `"@vizejs/nuxt"` to a Nuxt config's top-level `modules` array.
101
+ *
102
+ * Nuxt owns its own Vite instance, so the module is the supported integration
103
+ * point; adding `@vizejs/vite-plugin` to a Nuxt-managed Vite config would fight
104
+ * it.
105
+ */
106
+ export function injectNuxtModule(source: string): string | null {
107
+ if (source.includes("@vizejs/nuxt")) {
108
+ return null;
109
+ }
110
+ if (countConfigCalls(source, NUXT_CALLEE) !== 1) {
111
+ return null;
112
+ }
113
+ const modules = readTopLevelArray(source, NUXT_CALLEE, "modules");
114
+ if (modules !== null) {
115
+ return insertArrayEntry(source, modules.contentStart, '"@vizejs/nuxt"', modules.empty);
116
+ }
117
+ if (findTopLevelKey(source, NUXT_CALLEE, "modules") !== null) {
118
+ return null;
119
+ }
120
+ return source.replace(NUXT_OPENING, () => `defineNuxtConfig({\n modules: ["@vizejs/nuxt"],\n`);
121
+ }
122
+
123
+ /**
124
+ * Inserts `entry` as the first element of an array literal.
125
+ *
126
+ * Prepending keeps the user's existing entries in their original order and
127
+ * leaves their formatting alone.
128
+ */
129
+ function insertArrayEntry(
130
+ source: string,
131
+ contentStart: number,
132
+ entry: string,
133
+ empty: boolean,
134
+ ): string {
135
+ const suffix = empty ? "" : ", ";
136
+ const tail = empty ? source.slice(contentStart).replace(/^\s*/u, "") : source.slice(contentStart);
137
+ return `${source.slice(0, contentStart)}${entry}${suffix}${tail}`;
138
+ }
139
+
140
+ /**
141
+ * Inserts an import after the last existing top-level import.
142
+ *
143
+ * A config with no imports at all returns `null`: the safe insertion point is
144
+ * not obvious, and the file is unusual enough to be worth a human look.
145
+ */
146
+ function insertImport(source: string, importLine: string): string | null {
147
+ if (source.includes(importLine.trimEnd())) {
148
+ return source;
149
+ }
150
+ const imports = [
151
+ ...source.matchAll(
152
+ /^import[^\r\n]*(?:from\s+["'][^"']+["']|["'][^"']+["'])\s*;?[^\S\r\n]*(?:\r?\n|$)/gmu,
153
+ ),
154
+ ];
155
+ const lastImport = imports.at(-1);
156
+ if (lastImport === undefined || lastImport.index === undefined) {
157
+ return null;
158
+ }
159
+ const end = lastImport.index + lastImport[0].length;
160
+ return source.slice(0, end) + importLine + source.slice(end);
161
+ }
@@ -0,0 +1,202 @@
1
+ import type { ProjectDetection } from "./detect.js";
2
+ import { canInjectViteLint, hasTopLevelKey } from "./edit-config.js";
3
+ import { VITE_LINT_MERGE_SNIPPET, VITE_LINT_SNIPPET } from "./templates.js";
4
+
5
+ /**
6
+ * Oxlint config filenames the `oxlint` binary actually auto-discovers.
7
+ *
8
+ * Verified against oxlint 1.64: `.oxlintrc.json`, `.oxlintrc.jsonc` and
9
+ * `oxlint.config.ts` are read; `oxlint.config.mts`, `.js`, `.mjs`, `.cjs` and
10
+ * `.cts` produce a run byte-identical to having no config at all. The wider list
11
+ * in `setup/config.ts` treats all eight as configuration, which is #3474. `init`
12
+ * uses this narrower list so it never reports an unread file as configured.
13
+ */
14
+ export const DISCOVERED_OXLINT_CONFIG_FILES = [
15
+ ".oxlintrc.json",
16
+ ".oxlintrc.jsonc",
17
+ "oxlint.config.ts",
18
+ ] as const;
19
+
20
+ /** Filename `init` writes when the `oxlint` binary is the lint entry point. */
21
+ export const INIT_OXLINT_CONFIG_FILE = "oxlint.config.ts";
22
+
23
+ /**
24
+ * Where a project's Oxlint configuration has to live to be read.
25
+ *
26
+ * `vp lint` and `vp check` read the `lint` block of `vite.config.ts` and never
27
+ * read `.oxlintrc.json`; the `oxlint` and `oxlint-vize` binaries read
28
+ * `.oxlintrc.json` and never read `vite.config.ts`. Writing the wrong one leaves
29
+ * a project that looks configured, reports zero `vize/*` diagnostics and exits
30
+ * `0` -- the defect #3389 recorded and #3407 fixed. Every branch below therefore
31
+ * follows the command the project will actually run, not the file that is
32
+ * easiest to write.
33
+ */
34
+ export type LintTargetKind =
35
+ /** Vite+ project: the `lint` block in the Vite config is the only readable place. */
36
+ | "vite-plus"
37
+ /** No Vite+: the `oxlint` binary reads its own config file. */
38
+ | "oxlint"
39
+ /** Both entry points are in use; both files get written from one settings object. */
40
+ | "both"
41
+ /** Vite+ project whose Vite config cannot be edited safely. Nothing is written. */
42
+ | "manual";
43
+
44
+ export interface LintTarget {
45
+ readonly kind: LintTargetKind;
46
+ /** Vite config to receive the `lint` block, when one can be edited. */
47
+ readonly viteConfig: string | null;
48
+ /** Oxlint config file to write, when the `oxlint` binary is an entry point. */
49
+ readonly oxlintConfig: string | null;
50
+ /** Existing Oxlint config left untouched, if any. */
51
+ readonly preservedOxlintConfig: string | null;
52
+ /** Why this target was chosen. Always shown to the user. */
53
+ readonly reason: string;
54
+ /**
55
+ * Set when the project needs a Vite+ `lint` block that `init` will not write.
56
+ * The caller must print the snippet and must not claim lint is configured.
57
+ */
58
+ readonly blockedReason: string | null;
59
+ /** Snippet to paste when `blockedReason` is set. */
60
+ readonly blockedSnippet: string | null;
61
+ }
62
+
63
+ export interface LintTargetInput {
64
+ readonly detection: ProjectDetection;
65
+ /** Source of the single Vite config, or `null` when there is not exactly one. */
66
+ readonly viteSource: string | null;
67
+ }
68
+
69
+ /**
70
+ * Chooses which Oxlint configuration file(s) the project needs.
71
+ *
72
+ * The `oxlint` binary is treated as an entry point whenever the project already
73
+ * carries a discovered Oxlint config or runs `oxlint` from a script. A Vite+
74
+ * project that also does either gets both files, generated from the same preset
75
+ * and help level, because keeping one of them silently stale is the same class
76
+ * of bug as writing the wrong one.
77
+ */
78
+ export function resolveLintTarget(input: LintTargetInput): LintTarget {
79
+ const { detection } = input;
80
+ const existing = discoveredOxlintConfig(detection);
81
+ const runsOxlintBinary = existing !== null || hasOxlintScript(detection);
82
+
83
+ if (!detection.usesVitePlus) {
84
+ return {
85
+ kind: "oxlint",
86
+ viteConfig: null,
87
+ oxlintConfig: existing === null ? INIT_OXLINT_CONFIG_FILE : null,
88
+ preservedOxlintConfig: existing,
89
+ reason:
90
+ "no Vite+ detected, so `oxlint` is the lint entry point and reads " +
91
+ `${existing ?? INIT_OXLINT_CONFIG_FILE}`,
92
+ blockedReason: null,
93
+ blockedSnippet: null,
94
+ };
95
+ }
96
+
97
+ const viteConfig = detection.viteConfigs.length === 1 ? detection.viteConfigs[0]! : null;
98
+ const injectable = input.viteSource !== null && canInjectViteLint(input.viteSource);
99
+ if (!detection.hasVitePlusLintBlock && !injectable) {
100
+ const blocked = describeBlocked(detection, input.viteSource);
101
+ return {
102
+ kind: "manual",
103
+ viteConfig: null,
104
+ oxlintConfig: null,
105
+ preservedOxlintConfig: existing,
106
+ reason: "Vite+ detected, so `vp lint` reads the `lint` block in the Vite config",
107
+ blockedReason: blocked.reason,
108
+ blockedSnippet: blocked.snippet,
109
+ };
110
+ }
111
+
112
+ if (!runsOxlintBinary) {
113
+ return {
114
+ kind: "vite-plus",
115
+ viteConfig,
116
+ oxlintConfig: null,
117
+ preservedOxlintConfig: null,
118
+ reason:
119
+ "Vite+ detected, so `vp lint` reads the `lint` block in " +
120
+ `${viteConfig ?? "the Vite config"} and never reads .oxlintrc.json`,
121
+ blockedReason: null,
122
+ blockedSnippet: null,
123
+ };
124
+ }
125
+
126
+ return {
127
+ kind: "both",
128
+ viteConfig,
129
+ oxlintConfig: existing === null ? INIT_OXLINT_CONFIG_FILE : null,
130
+ preservedOxlintConfig: existing,
131
+ reason:
132
+ "Vite+ and the `oxlint` binary are both in use, so the `lint` block in " +
133
+ `${viteConfig ?? "the Vite config"} and ${existing ?? INIT_OXLINT_CONFIG_FILE} ` +
134
+ "are written from the same preset",
135
+ blockedReason: null,
136
+ blockedSnippet: null,
137
+ };
138
+ }
139
+
140
+ /**
141
+ * The existing Oxlint config, restricted to names Oxlint actually reads.
142
+ *
143
+ * A project holding only `oxlint.config.mjs` is deliberately treated as having
144
+ * no Oxlint config, because that is how Oxlint treats it.
145
+ */
146
+ export function discoveredOxlintConfig(detection: ProjectDetection): string | null {
147
+ const existing = detection.oxlintConfig;
148
+ if (existing === null) {
149
+ return null;
150
+ }
151
+ return (DISCOVERED_OXLINT_CONFIG_FILES as readonly string[]).includes(existing) ? existing : null;
152
+ }
153
+
154
+ /** An Oxlint config file that is present but which Oxlint will never read. */
155
+ export function unreadOxlintConfig(detection: ProjectDetection): string | null {
156
+ const existing = detection.oxlintConfig;
157
+ if (existing === null || discoveredOxlintConfig(detection) !== null) {
158
+ return null;
159
+ }
160
+ return existing;
161
+ }
162
+
163
+ function hasOxlintScript(detection: ProjectDetection): boolean {
164
+ return Object.values(detection.scripts).some((command) =>
165
+ /(?:^|[\s&|;])oxlint(?:-vize)?(?:\s|$)/u.test(command),
166
+ );
167
+ }
168
+
169
+ /**
170
+ * Why the `lint` block will not be written.
171
+ *
172
+ * The message is the whole value of a blocked result, so it names the specific
173
+ * obstacle instead of a generic "could not edit". An existing `lint` block in
174
+ * particular is a merge the user has to make, not a failure of the file.
175
+ */
176
+ function describeBlocked(
177
+ detection: ProjectDetection,
178
+ viteSource: string | null,
179
+ ): { readonly reason: string; readonly snippet: string } {
180
+ if (detection.viteConfigs.length === 0) {
181
+ return { reason: "no vite.config file to hold the `lint` block", snippet: VITE_LINT_SNIPPET };
182
+ }
183
+ if (detection.viteConfigs.length > 1) {
184
+ return {
185
+ reason: `several Vite configs (${detection.viteConfigs.join(", ")}), so the target is ambiguous`,
186
+ snippet: VITE_LINT_SNIPPET,
187
+ };
188
+ }
189
+ const filename = detection.viteConfigs[0]!;
190
+ if (viteSource !== null && hasTopLevelKey(viteSource, "lint")) {
191
+ return {
192
+ reason:
193
+ `${filename} already has a \`lint\` block and merging into it would risk dropping ` +
194
+ "settings, so spread createVizeLintConfig() into it by hand",
195
+ snippet: VITE_LINT_MERGE_SNIPPET,
196
+ };
197
+ }
198
+ return {
199
+ reason: `${filename} is not a single plain defineConfig({ ... }) call`,
200
+ snippet: VITE_LINT_SNIPPET,
201
+ };
202
+ }
@@ -0,0 +1,97 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import type { ProjectDetection } from "./detect.js";
5
+ import { injectNuxtModule, injectVitePlugin } from "./edit-config.js";
6
+ import { skipped, type PlanDraft } from "./plan-types.js";
7
+
8
+ /**
9
+ * Plans the bundler integration: the Vite plugin, or the Nuxt module.
10
+ *
11
+ * Nuxt outranks Vite because a Nuxt project owns its own Vite instance --
12
+ * adding `@vizejs/vite-plugin` to a Nuxt-managed Vite config would fight the
13
+ * module rather than complement it.
14
+ *
15
+ * @returns the possibly-edited Vite config source, or the input unchanged.
16
+ */
17
+ export function planBundler(
18
+ detection: ProjectDetection,
19
+ viteDraft: string | null,
20
+ draft: PlanDraft,
21
+ ): string | null {
22
+ if (detection.framework === "nuxt") {
23
+ planNuxtModule(detection, draft);
24
+ return viteDraft;
25
+ }
26
+ if (detection.framework !== "vite" || detection.viteConfigs.length !== 1) {
27
+ draft.features.push(skipped("bundler", "no single vite.config or nuxt.config to configure"));
28
+ return viteDraft;
29
+ }
30
+ draft.dependencies.add("@vizejs/vite-plugin");
31
+ const filename = detection.viteConfigs[0]!;
32
+ if (detection.hasVizeVitePlugin) {
33
+ draft.features.push({
34
+ id: "bundler",
35
+ outcome: "unchanged",
36
+ detail: `${filename} already uses @vizejs/vite-plugin`,
37
+ snippet: null,
38
+ });
39
+ return viteDraft;
40
+ }
41
+ const injected = viteDraft === null ? null : injectVitePlugin(viteDraft);
42
+ if (injected === null) {
43
+ draft.features.push({
44
+ id: "bundler",
45
+ outcome: "blocked",
46
+ detail: `${filename} has no plugins array this tool can extend safely`,
47
+ snippet: "plugins: [vize()]",
48
+ });
49
+ return viteDraft;
50
+ }
51
+ draft.features.push({
52
+ id: "bundler",
53
+ outcome: "configured",
54
+ detail: `adds vize() to ${filename}`,
55
+ snippet: null,
56
+ });
57
+ return injected;
58
+ }
59
+
60
+ function planNuxtModule(detection: ProjectDetection, draft: PlanDraft): void {
61
+ draft.dependencies.add("@vizejs/nuxt");
62
+ if (detection.nuxtConfig === null) {
63
+ draft.features.push(skipped("bundler", "no nuxt.config file to add @vizejs/nuxt to"));
64
+ return;
65
+ }
66
+ if (detection.hasVizeNuxtModule) {
67
+ draft.features.push({
68
+ id: "bundler",
69
+ outcome: "unchanged",
70
+ detail: `${detection.nuxtConfig} already lists @vizejs/nuxt`,
71
+ snippet: null,
72
+ });
73
+ return;
74
+ }
75
+ const source = fs.readFileSync(path.join(detection.root, detection.nuxtConfig), "utf8");
76
+ const injected = injectNuxtModule(source);
77
+ if (injected === null) {
78
+ draft.features.push({
79
+ id: "bundler",
80
+ outcome: "blocked",
81
+ detail: `${detection.nuxtConfig} is not a single plain defineNuxtConfig({ ... }) call`,
82
+ snippet: 'modules: ["@vizejs/nuxt"]',
83
+ });
84
+ return;
85
+ }
86
+ draft.files.push({
87
+ filename: path.join(detection.root, detection.nuxtConfig),
88
+ source: injected,
89
+ });
90
+ draft.updatedFiles.push(detection.nuxtConfig);
91
+ draft.features.push({
92
+ id: "bundler",
93
+ outcome: "configured",
94
+ detail: `adds @vizejs/nuxt to ${detection.nuxtConfig}`,
95
+ snippet: null,
96
+ });
97
+ }