Skip to content

Commit 6be3c3a

Browse files
authored
feat(cli): Rsbuild template detection and scaffolding in arkenv init (#1809)
1 parent eff1689 commit 6be3c3a

21 files changed

Lines changed: 646 additions & 6 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"arkenv": minor
3+
---
4+
5+
#### feat(cli): Rsbuild template detection and scaffolding in `arkenv init`
6+
7+
Added first-class support for Rsbuild in `arkenv init`:
8+
- Detects `@rsbuild/core` in project dependencies or `rsbuild.config.*` files.
9+
- Adds `@arkenv/rsbuild-plugin` to project dependencies during initialization.
10+
- Bootstraps `rsbuild.config.*` files by injecting `arkenvRsbuildPlugin()`.
11+
- Sets default client prefix to `PUBLIC_`.

apps/www/content/docs/reference/init.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,8 @@ Depending on the detected framework and flags, `init` writes:
111111
defaults. If `.env` is missing, init may copy `.env.example` into it.
112112
- Next.js: `withArkEnv` in `next.config.*`, `.arkenv/` in `.gitignore`,
113113
optional `.arkenv/env.gen.ts` (import as `@/.arkenv`)
114-
- Vite / Bun: plugin configuration in `vite.config.*` / `bunfig.toml`
114+
- Vite / Bun / Rsbuild: plugin configuration in `vite.config.*` /
115+
`bunfig.toml` / `rsbuild.config.*`
115116
- Dependency installs for the chosen dialect (`@arkenv/core`, plugins,
116117
Zod/Valibot)
117118

packages/arkenv/src/adapters/node-project-scanner/node-project-scanner.adapter.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,21 @@ API_KEY=
128128
expect(result).toBe("vite");
129129
});
130130

131+
it("detects rsbuild from package.json dependencies", async () => {
132+
await fsp.writeFile(
133+
path.join(tempDir, "package.json"),
134+
JSON.stringify({ dependencies: { "@rsbuild/core": "^1.0.0" } }),
135+
);
136+
const result = await scanner.detectFramework(tempDir);
137+
expect(result).toBe("rsbuild");
138+
});
139+
140+
it("detects rsbuild from rsbuild.config.ts", async () => {
141+
await fsp.writeFile(path.join(tempDir, "rsbuild.config.ts"), "");
142+
const result = await scanner.detectFramework(tempDir);
143+
expect(result).toBe("rsbuild");
144+
});
145+
131146
it("detects bun-fullstack from tsconfig types and feature presence", async () => {
132147
await fsp.writeFile(
133148
path.join(tempDir, "server.ts"),

packages/arkenv/src/adapters/node-project-scanner/node-project-scanner.adapter.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,9 @@ export class NodeProjectScannerAdapter implements ProjectScannerPort {
110110
async detectFramework(
111111
cwd = process.cwd(),
112112
tsConfig?: ParsedTsConfig | null,
113-
): Promise<"vite" | "bun-fullstack" | "vanilla" | "nextjs" | "nuxt"> {
113+
): Promise<
114+
"vite" | "bun-fullstack" | "vanilla" | "nextjs" | "nuxt" | "rsbuild"
115+
> {
114116
return detectFramework(cwd, tsConfig);
115117
}
116118

packages/arkenv/src/adapters/node-project-scanner/utils/detector.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ async function hasConfig(cwd: string, files: string[]): Promise<boolean> {
2222
export async function detectFramework(
2323
cwd = process.cwd(),
2424
tsConfig?: ParsedTsConfig | null,
25-
): Promise<"vite" | "bun-fullstack" | "vanilla" | "nextjs" | "nuxt"> {
25+
): Promise<
26+
"vite" | "bun-fullstack" | "vanilla" | "nextjs" | "nuxt" | "rsbuild"
27+
> {
2628
if (tsConfig?.compilerOptions?.types) {
2729
const types = tsConfig.compilerOptions.types;
2830
if (types.includes("vite") || types.includes("vite/client")) return "vite";
@@ -42,6 +44,7 @@ export async function detectFramework(
4244
if (allDeps.vite) return "vite";
4345
if (allDeps.next) return "nextjs";
4446
if (allDeps.nuxt) return "nuxt";
47+
if (allDeps["@rsbuild/core"]) return "rsbuild";
4548
} catch {
4649
// ignore missing or invalid package.json
4750
}
@@ -71,6 +74,17 @@ export async function detectFramework(
7174
) {
7275
return "nuxt";
7376
}
77+
if (
78+
await hasConfig(cwd, [
79+
"rsbuild.config.ts",
80+
"rsbuild.config.js",
81+
"rsbuild.config.mjs",
82+
"rsbuild.config.cjs",
83+
"rsbuild.config.mts",
84+
])
85+
) {
86+
return "rsbuild";
87+
}
7488

7589
// Bun Detection
7690
const features = await detectBunFeatures(cwd, tsConfig);

packages/arkenv/src/adapters/node-workspace/node-workspace.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,4 +227,44 @@ describe("NodeWorkspace", () => {
227227
expect(result.success).toBe(true);
228228
expect(result.updated).toBe(false);
229229
});
230+
231+
it("finds rsbuild config files", async () => {
232+
await fsp.writeFile(path.join(tempDir, "rsbuild.config.ts"), "");
233+
const found = await workspace.findRsbuildConfig();
234+
expect(found).toContain("rsbuild.config.ts");
235+
});
236+
237+
it("bootstraps rsbuild config by injecting arkenvRsbuildPlugin", async () => {
238+
const rsbuildConfig = dedent`
239+
import { defineConfig } from "@rsbuild/core";
240+
export default defineConfig({
241+
plugins: []
242+
});
243+
`;
244+
const configPath = path.join(tempDir, "rsbuild.config.ts");
245+
await fsp.writeFile(configPath, rsbuildConfig);
246+
247+
const result = await workspace.bootstrapRsbuildConfig(configPath);
248+
expect(result.success).toBe(true);
249+
expect(result.updated).toBe(true);
250+
251+
const updated = await fsp.readFile(configPath, "utf-8");
252+
expect(updated).toContain('from "@arkenv/rsbuild-plugin"');
253+
expect(updated).toContain("arkenvRsbuildPlugin()");
254+
});
255+
256+
it("is idempotent when bootstrapping rsbuild config that already has the plugin", async () => {
257+
const rsbuildConfig = dedent`
258+
import { arkenvRsbuildPlugin } from "@arkenv/rsbuild-plugin";
259+
export default {
260+
plugins: [arkenvRsbuildPlugin()]
261+
};
262+
`;
263+
const configPath = path.join(tempDir, "rsbuild.config.ts");
264+
await fsp.writeFile(configPath, rsbuildConfig);
265+
266+
const result = await workspace.bootstrapRsbuildConfig(configPath);
267+
expect(result.success).toBe(true);
268+
expect(result.updated).toBe(false);
269+
});
230270
});

packages/arkenv/src/adapters/node-workspace/node-workspace.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@ import {
1111
bootstrapBunConfig,
1212
bootstrapNextjsConfig,
1313
bootstrapNuxtConfig,
14+
bootstrapRsbuildConfig,
1415
bootstrapViteConfig,
1516
findBunConfig,
1617
findNextjsConfig,
1718
findNuxtConfig,
19+
findRsbuildConfig,
1820
findViteConfig,
1921
} from "./utils/bootstrappers";
2022
import {
@@ -126,6 +128,10 @@ export class NodeWorkspace implements WorkspacePort {
126128
return findNuxtConfig(cwd);
127129
}
128130

131+
async findRsbuildConfig(cwd?: string): Promise<string | null> {
132+
return findRsbuildConfig(cwd);
133+
}
134+
129135
async bootstrapViteConfig(
130136
filePath: string,
131137
importPath: string,
@@ -151,6 +157,10 @@ export class NodeWorkspace implements WorkspacePort {
151157
return bootstrapNuxtConfig(this, filePath);
152158
}
153159

160+
async bootstrapRsbuildConfig(filePath: string): Promise<BootstrapResult> {
161+
return bootstrapRsbuildConfig(this, filePath);
162+
}
163+
154164
async appendMissingEnvExampleKeys(
155165
cwd: string,
156166
keys: string[],

packages/arkenv/src/adapters/node-workspace/utils/bootstrappers.test.ts

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
33
import {
44
transformNextjsConfig,
55
transformNuxtConfig,
6+
transformRsbuildConfig,
67
transformViteConfig,
78
} from "./bootstrappers";
89

@@ -324,4 +325,203 @@ describe("bootstrappers", () => {
324325
expect(result.code?.endsWith("\n")).toBe(true);
325326
});
326327
});
328+
329+
describe("transformRsbuildConfig", () => {
330+
it("injects plugin into a standard rsbuild.config.ts", async () => {
331+
const initialContent = dedent`
332+
import { defineConfig } from "@rsbuild/core"
333+
export default defineConfig({
334+
plugins: []
335+
})
336+
`;
337+
338+
const result = transformRsbuildConfig({ code: initialContent });
339+
expect(result.success).toBe(true);
340+
341+
expect(result.code).toContain(
342+
'import { arkenvRsbuildPlugin } from "@arkenv/rsbuild-plugin"',
343+
);
344+
expect(result.code).toContain("arkenvRsbuildPlugin()");
345+
});
346+
347+
it("injects plugin into a simple object export", async () => {
348+
const initialContent = dedent`
349+
export default {
350+
plugins: []
351+
}
352+
`;
353+
354+
const result = transformRsbuildConfig({ code: initialContent });
355+
expect(result.success).toBe(true);
356+
expect(result.code).toContain("arkenvRsbuildPlugin()");
357+
});
358+
359+
it("handles missing plugins array", async () => {
360+
const initialContent = dedent`
361+
export default {
362+
server: {}
363+
}
364+
`;
365+
366+
const result = transformRsbuildConfig({ code: initialContent });
367+
expect(result.success).toBe(true);
368+
expect(result.code).toContain("plugins: [");
369+
expect(result.code).toContain("arkenvRsbuildPlugin()");
370+
});
371+
372+
it("preserves space indentation format", () => {
373+
const initialContent =
374+
'import { defineConfig } from "@rsbuild/core";\n\nexport default defineConfig({\n plugins: [],\n});\n';
375+
const result = transformRsbuildConfig({ code: initialContent });
376+
expect(result.success).toBe(true);
377+
expect(result.code).toContain(" plugins: [arkenvRsbuildPlugin()]");
378+
expect(result.code?.endsWith("\n")).toBe(true);
379+
});
380+
381+
it("preserves tab indentation format", () => {
382+
const initialContent =
383+
'import { defineConfig } from "@rsbuild/core";\n\nexport default defineConfig({\n\tplugins: [],\n});\n';
384+
const result = transformRsbuildConfig({ code: initialContent });
385+
expect(result.success).toBe(true);
386+
expect(result.code).toContain("\tplugins: [arkenvRsbuildPlugin()]");
387+
});
388+
389+
it("preserves absence of trailing newline", () => {
390+
const initialContent = "export default { plugins: [] }";
391+
const result = transformRsbuildConfig({ code: initialContent });
392+
expect(result.success).toBe(true);
393+
expect(result.code?.endsWith("\n")).toBe(false);
394+
});
395+
396+
it("refuses defineConfig callback form with an actionable message", () => {
397+
const initialContent =
398+
'import { defineConfig } from "@rsbuild/core";\nexport default defineConfig((env) => ({\n plugins: [],\n}));';
399+
const result = transformRsbuildConfig({ code: initialContent });
400+
expect(result.success).toBe(false);
401+
if (!result.success) {
402+
expect(result.error).toContain(
403+
"The 'defineConfig' callback form is currently not supported",
404+
);
405+
}
406+
});
407+
408+
it("fails when default export is not an object", () => {
409+
const initialContent = "export default 123;";
410+
const result = transformRsbuildConfig({ code: initialContent });
411+
expect(result.success).toBe(false);
412+
if (!result.success) {
413+
expect(result.error).toContain(
414+
"Could not find default export object in Rsbuild config",
415+
);
416+
}
417+
});
418+
419+
it("fails when plugins property is not an array", () => {
420+
const initialContent = "export default { plugins: 123 };";
421+
const result = transformRsbuildConfig({ code: initialContent });
422+
expect(result.success).toBe(false);
423+
if (!result.success) {
424+
expect(result.error).toContain(
425+
"The 'plugins' property in your Rsbuild config is not an array",
426+
);
427+
}
428+
});
429+
430+
it("does not duplicate plugin if already exists and returns updated: false", async () => {
431+
const initialContent = dedent`
432+
import { arkenvRsbuildPlugin } from "@arkenv/rsbuild-plugin"
433+
export default {
434+
plugins: [arkenvRsbuildPlugin()]
435+
}
436+
`;
437+
438+
const result = transformRsbuildConfig({ code: initialContent });
439+
expect(result.success).toBe(true);
440+
expect(result.updated).toBe(false);
441+
});
442+
443+
it("is idempotent when arkenvRsbuildPlugin is aliased in the import", async () => {
444+
const initialContent = dedent`
445+
import { arkenvRsbuildPlugin as myPlugin } from "@arkenv/rsbuild-plugin"
446+
export default {
447+
plugins: [myPlugin()]
448+
}
449+
`;
450+
451+
const result = transformRsbuildConfig({ code: initialContent });
452+
expect(result.success).toBe(true);
453+
expect(result.updated).toBe(false);
454+
});
455+
456+
it("returns updated: true when plugin is injected", async () => {
457+
const initialContent = dedent`
458+
export default {
459+
plugins: []
460+
}
461+
`;
462+
463+
const result = transformRsbuildConfig({ code: initialContent });
464+
expect(result.success).toBe(true);
465+
expect(result.updated).toBe(true);
466+
});
467+
468+
it("injects plugin into plugins array even if import already exists but is unregistered", async () => {
469+
const initialContent = dedent`
470+
import { arkenvRsbuildPlugin } from "@arkenv/rsbuild-plugin"
471+
export default {
472+
plugins: []
473+
}
474+
`;
475+
476+
const result = transformRsbuildConfig({ code: initialContent });
477+
expect(result.success).toBe(true);
478+
expect(result.updated).toBe(true);
479+
expect(result.code).toContain("plugins: [arkenvRsbuildPlugin()]");
480+
});
481+
482+
it("injects aliased plugin call when an unregistered aliased import exists", async () => {
483+
const initialContent = dedent`
484+
import { arkenvRsbuildPlugin as customPlugin } from "@arkenv/rsbuild-plugin"
485+
export default {
486+
plugins: []
487+
}
488+
`;
489+
490+
const result = transformRsbuildConfig({ code: initialContent });
491+
expect(result.success).toBe(true);
492+
expect(result.updated).toBe(true);
493+
expect(result.code).toContain("plugins: [customPlugin()]");
494+
expect(result.code).not.toContain("arkenvRsbuildPlugin()");
495+
});
496+
497+
it("prefers the plugin function when options type is imported before it", async () => {
498+
const initialContent = dedent`
499+
import { type RsbuildTransformOptions, arkenvRsbuildPlugin } from "@arkenv/rsbuild-plugin"
500+
export default {
501+
plugins: []
502+
}
503+
`;
504+
505+
const result = transformRsbuildConfig({ code: initialContent });
506+
expect(result.success).toBe(true);
507+
expect(result.updated).toBe(true);
508+
expect(result.code).toContain("plugins: [arkenvRsbuildPlugin()]");
509+
expect(result.code).not.toContain("RsbuildTransformOptions()");
510+
});
511+
512+
it("injects plugin into a non-empty plugins array preserving existing entries", async () => {
513+
const initialContent = dedent`
514+
import { pluginReact } from "@rsbuild/plugin-react"
515+
export default {
516+
plugins: [pluginReact()]
517+
}
518+
`;
519+
520+
const result = transformRsbuildConfig({ code: initialContent });
521+
expect(result.success).toBe(true);
522+
expect(result.updated).toBe(true);
523+
expect(result.code).toContain("pluginReact()");
524+
expect(result.code).toContain("arkenvRsbuildPlugin()");
525+
});
526+
});
327527
});

0 commit comments

Comments
 (0)