Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/rsbuild-init-scaffold.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"arkenv": minor
---

#### feat(cli): Rsbuild template detection and scaffolding in `arkenv init`

Added first-class support for Rsbuild in `arkenv init`:
- Detects `@rsbuild/core` in project dependencies or `rsbuild.config.*` files.
- Adds `@arkenv/rsbuild-plugin` to project dependencies during initialization.
- Bootstraps `rsbuild.config.*` files by injecting `arkenvRsbuildPlugin()`.
- Sets default client prefix to `PUBLIC_`.
3 changes: 2 additions & 1 deletion apps/www/content/docs/reference/init.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,8 @@ Depending on the detected framework and flags, `init` writes:
defaults. If `.env` is missing, init may copy `.env.example` into it.
- Next.js: `withArkEnv` in `next.config.*`, `.arkenv/` in `.gitignore`,
optional `.arkenv/env.gen.ts` (import as `@/.arkenv`)
- Vite / Bun: plugin configuration in `vite.config.*` / `bunfig.toml`
- Vite / Bun / Rsbuild: plugin configuration in `vite.config.*` /
`bunfig.toml` / `rsbuild.config.*`
- Dependency installs for the chosen dialect (`@arkenv/core`, plugins,
Zod/Valibot)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,21 @@ API_KEY=
expect(result).toBe("vite");
});

it("detects rsbuild from package.json dependencies", async () => {
await fsp.writeFile(
path.join(tempDir, "package.json"),
JSON.stringify({ dependencies: { "@rsbuild/core": "^1.0.0" } }),
);
const result = await scanner.detectFramework(tempDir);
expect(result).toBe("rsbuild");
});

it("detects rsbuild from rsbuild.config.ts", async () => {
await fsp.writeFile(path.join(tempDir, "rsbuild.config.ts"), "");
const result = await scanner.detectFramework(tempDir);
expect(result).toBe("rsbuild");
});

it("detects bun-fullstack from tsconfig types and feature presence", async () => {
await fsp.writeFile(
path.join(tempDir, "server.ts"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,9 @@ export class NodeProjectScannerAdapter implements ProjectScannerPort {
async detectFramework(
cwd = process.cwd(),
tsConfig?: ParsedTsConfig | null,
): Promise<"vite" | "bun-fullstack" | "vanilla" | "nextjs" | "nuxt"> {
): Promise<
"vite" | "bun-fullstack" | "vanilla" | "nextjs" | "nuxt" | "rsbuild"
> {
return detectFramework(cwd, tsConfig);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ async function hasConfig(cwd: string, files: string[]): Promise<boolean> {
export async function detectFramework(
cwd = process.cwd(),
tsConfig?: ParsedTsConfig | null,
): Promise<"vite" | "bun-fullstack" | "vanilla" | "nextjs" | "nuxt"> {
): Promise<
"vite" | "bun-fullstack" | "vanilla" | "nextjs" | "nuxt" | "rsbuild"
> {
if (tsConfig?.compilerOptions?.types) {
const types = tsConfig.compilerOptions.types;
if (types.includes("vite") || types.includes("vite/client")) return "vite";
Expand All @@ -42,6 +44,7 @@ export async function detectFramework(
if (allDeps.vite) return "vite";
if (allDeps.next) return "nextjs";
if (allDeps.nuxt) return "nuxt";
if (allDeps["@rsbuild/core"]) return "rsbuild";
} catch {
// ignore missing or invalid package.json
}
Expand Down Expand Up @@ -71,6 +74,17 @@ export async function detectFramework(
) {
return "nuxt";
}
if (
await hasConfig(cwd, [
"rsbuild.config.ts",
"rsbuild.config.js",
"rsbuild.config.mjs",
"rsbuild.config.cjs",
"rsbuild.config.mts",
])
) {
return "rsbuild";
}

// Bun Detection
const features = await detectBunFeatures(cwd, tsConfig);
Expand Down
40 changes: 40 additions & 0 deletions packages/arkenv/src/adapters/node-workspace/node-workspace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,4 +227,44 @@ describe("NodeWorkspace", () => {
expect(result.success).toBe(true);
expect(result.updated).toBe(false);
});

it("finds rsbuild config files", async () => {
await fsp.writeFile(path.join(tempDir, "rsbuild.config.ts"), "");
const found = await workspace.findRsbuildConfig();
expect(found).toContain("rsbuild.config.ts");
});

it("bootstraps rsbuild config by injecting arkenvRsbuildPlugin", async () => {
const rsbuildConfig = dedent`
import { defineConfig } from "@rsbuild/core";
export default defineConfig({
plugins: []
});
`;
const configPath = path.join(tempDir, "rsbuild.config.ts");
await fsp.writeFile(configPath, rsbuildConfig);

const result = await workspace.bootstrapRsbuildConfig(configPath);
expect(result.success).toBe(true);
expect(result.updated).toBe(true);

const updated = await fsp.readFile(configPath, "utf-8");
expect(updated).toContain('from "@arkenv/rsbuild-plugin"');
expect(updated).toContain("arkenvRsbuildPlugin()");
});

it("is idempotent when bootstrapping rsbuild config that already has the plugin", async () => {
const rsbuildConfig = dedent`
import { arkenvRsbuildPlugin } from "@arkenv/rsbuild-plugin";
export default {
plugins: [arkenvRsbuildPlugin()]
};
`;
const configPath = path.join(tempDir, "rsbuild.config.ts");
await fsp.writeFile(configPath, rsbuildConfig);

const result = await workspace.bootstrapRsbuildConfig(configPath);
expect(result.success).toBe(true);
expect(result.updated).toBe(false);
});
});
10 changes: 10 additions & 0 deletions packages/arkenv/src/adapters/node-workspace/node-workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ import {
bootstrapBunConfig,
bootstrapNextjsConfig,
bootstrapNuxtConfig,
bootstrapRsbuildConfig,
bootstrapViteConfig,
findBunConfig,
findNextjsConfig,
findNuxtConfig,
findRsbuildConfig,
findViteConfig,
} from "./utils/bootstrappers";
import {
Expand Down Expand Up @@ -126,6 +128,10 @@ export class NodeWorkspace implements WorkspacePort {
return findNuxtConfig(cwd);
}

async findRsbuildConfig(cwd?: string): Promise<string | null> {
return findRsbuildConfig(cwd);
}

async bootstrapViteConfig(
filePath: string,
importPath: string,
Expand All @@ -151,6 +157,10 @@ export class NodeWorkspace implements WorkspacePort {
return bootstrapNuxtConfig(this, filePath);
}

async bootstrapRsbuildConfig(filePath: string): Promise<BootstrapResult> {
return bootstrapRsbuildConfig(this, filePath);
}

async appendMissingEnvExampleKeys(
cwd: string,
keys: string[],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
transformNextjsConfig,
transformNuxtConfig,
transformRsbuildConfig,
transformViteConfig,
} from "./bootstrappers";

Expand Down Expand Up @@ -324,4 +325,173 @@ describe("bootstrappers", () => {
expect(result.code?.endsWith("\n")).toBe(true);
});
});

describe("transformRsbuildConfig", () => {
it("injects plugin into a standard rsbuild.config.ts", async () => {
const initialContent = dedent`
import { defineConfig } from "@rsbuild/core"
export default defineConfig({
plugins: []
})
`;

const result = transformRsbuildConfig({ code: initialContent });
expect(result.success).toBe(true);

expect(result.code).toContain(
'import { arkenvRsbuildPlugin } from "@arkenv/rsbuild-plugin"',
);
expect(result.code).toContain("arkenvRsbuildPlugin()");
});

it("injects plugin into a simple object export", async () => {
const initialContent = dedent`
export default {
plugins: []
}
`;

const result = transformRsbuildConfig({ code: initialContent });
expect(result.success).toBe(true);
expect(result.code).toContain("arkenvRsbuildPlugin()");
});

it("handles missing plugins array", async () => {
const initialContent = dedent`
export default {
server: {}
}
`;

const result = transformRsbuildConfig({ code: initialContent });
expect(result.success).toBe(true);
expect(result.code).toContain("plugins: [");
expect(result.code).toContain("arkenvRsbuildPlugin()");
});

it("preserves space indentation format", () => {
const initialContent =
'import { defineConfig } from "@rsbuild/core";\n\nexport default defineConfig({\n plugins: [],\n});\n';
const result = transformRsbuildConfig({ code: initialContent });
expect(result.success).toBe(true);
expect(result.code).toContain(" plugins: [arkenvRsbuildPlugin()]");
expect(result.code?.endsWith("\n")).toBe(true);
});

it("preserves tab indentation format", () => {
const initialContent =
'import { defineConfig } from "@rsbuild/core";\n\nexport default defineConfig({\n\tplugins: [],\n});\n';
const result = transformRsbuildConfig({ code: initialContent });
expect(result.success).toBe(true);
expect(result.code).toContain("\tplugins: [arkenvRsbuildPlugin()]");
});

it("preserves absence of trailing newline", () => {
const initialContent = "export default { plugins: [] }";
const result = transformRsbuildConfig({ code: initialContent });
expect(result.success).toBe(true);
expect(result.code?.endsWith("\n")).toBe(false);
});

it("refuses defineConfig callback form with an actionable message", () => {
const initialContent =
'import { defineConfig } from "@rsbuild/core";\nexport default defineConfig((env) => ({\n plugins: [],\n}));';
const result = transformRsbuildConfig({ code: initialContent });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toContain(
"The 'defineConfig' callback form is currently not supported",
);
}
});

it("fails when default export is not an object", () => {
const initialContent = "export default 123;";
const result = transformRsbuildConfig({ code: initialContent });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toContain(
"Could not find default export object in Rsbuild config",
);
}
});

it("fails when plugins property is not an array", () => {
const initialContent = "export default { plugins: 123 };";
const result = transformRsbuildConfig({ code: initialContent });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toContain(
"The 'plugins' property in your Rsbuild config is not an array",
);
}
});

it("does not duplicate plugin if already exists and returns updated: false", async () => {
const initialContent = dedent`
import { arkenvRsbuildPlugin } from "@arkenv/rsbuild-plugin"
export default {
plugins: [arkenvRsbuildPlugin()]
}
`;

const result = transformRsbuildConfig({ code: initialContent });
expect(result.success).toBe(true);
expect(result.updated).toBe(false);
});

it("is idempotent when arkenvRsbuildPlugin is aliased in the import", async () => {
const initialContent = dedent`
import { arkenvRsbuildPlugin as myPlugin } from "@arkenv/rsbuild-plugin"
export default {
plugins: [myPlugin()]
}
`;

const result = transformRsbuildConfig({ code: initialContent });
expect(result.success).toBe(true);
expect(result.updated).toBe(false);
});

it("returns updated: true when plugin is injected", async () => {
const initialContent = dedent`
export default {
plugins: []
}
`;

const result = transformRsbuildConfig({ code: initialContent });
expect(result.success).toBe(true);
expect(result.updated).toBe(true);
});

it("injects plugin into plugins array even if import already exists but is unregistered", async () => {
const initialContent = dedent`
import { arkenvRsbuildPlugin } from "@arkenv/rsbuild-plugin"
export default {
plugins: []
}
`;

const result = transformRsbuildConfig({ code: initialContent });
expect(result.success).toBe(true);
expect(result.updated).toBe(true);
expect(result.code).toContain("plugins: [arkenvRsbuildPlugin()]");
});

it("injects aliased plugin call when an unregistered aliased import exists", async () => {
const initialContent = dedent`
import { arkenvRsbuildPlugin as customPlugin } from "@arkenv/rsbuild-plugin"
export default {
plugins: []
}
`;

const result = transformRsbuildConfig({ code: initialContent });
expect(result.success).toBe(true);
expect(result.updated).toBe(true);
expect(result.code).toContain("plugins: [customPlugin()]");
expect(result.code).not.toContain("arkenvRsbuildPlugin()");
});
});
});
Loading
Loading