Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@
"test:relay": "jest --testPathPatterns=Relay",
"test:locale": "jest --testPathPatterns=Locale",
"test:manifest": "jest --testPathPatterns=src/cli/builders/manifest",
"test:typescript": "jest --testPathPatterns=src/cli/builders/typescript",
"test:entrypoint": "jest --testPathPatterns=src/cli/entrypoint",
"test:plugins": "jest --testPathPatterns=src/cli/plugins",
"release": "release-it",
Expand Down
188 changes: 188 additions & 0 deletions src/cli/builders/typescript/ConfigBuilder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import {ConfigBuilder} from "./ConfigBuilder";

describe("TypeScript ConfigBuilder", () => {
let cfg: ReturnType<typeof ConfigBuilder.from>;

beforeEach(() => {
cfg = ConfigBuilder.from();
});

it("sets and gets nested value", () => {
cfg.set("compilerOptions.target", "esnext");

expect(cfg.get("compilerOptions.target")).toBe("esnext");
});

it("creates missing nested objects on set", () => {
cfg.set("compilerOptions.strict", true);

expect(cfg.get("compilerOptions")).toEqual({
strict: true,
});
});

it("supports creating multiple nested properties across different paths", () => {
cfg.set("compilerOptions.types", ["jest"]);
cfg.set("compilerOptions.lib", ["es2020"]);

expect(cfg.get("compilerOptions")).toEqual({
types: ["jest"],
lib: ["es2020"],
});
});

it("returns undefined for a non-existing path", () => {
expect(cfg.get("compilerOptions.module")).toBeUndefined();
});

it("has() returns true for an existing path", () => {
cfg.set("compilerOptions.strict", true);

expect(cfg.has("compilerOptions.strict")).toBe(true);
});

it("has() returns false for a non-existing path", () => {
expect(cfg.has("compilerOptions.module")).toBe(false);
});

it("deletes a nested property", () => {
cfg.set("compilerOptions.module", "commonjs");
cfg.delete("compilerOptions.module");

expect(cfg.get("compilerOptions.module")).toBeUndefined();
});

it("delete does nothing for non-existing path", () => {
expect(() => cfg.delete("compilerOptions.module")).not.toThrow();
});

it("set/delete/merge/raw return the builder instance for chaining", () => {
const a = cfg.set("compilerOptions.target", "esnext");
const b = cfg.delete("compilerOptions.baseUrl");
const c = cfg.merge({include: ["src/**/*.ts"]});
const d = cfg.raw({compilerOptions: {strict: true}});
expect(a).toBe(cfg);
expect(b).toBe(cfg);
expect(c).toBe(cfg);
expect(d).toBe(cfg);
});

it("deep-merges objects without removing existing keys", () => {
cfg.set("compilerOptions.target", "esnext");

cfg.merge({
compilerOptions: {
strict: true,
},
});

expect(cfg.get()).toEqual({
compilerOptions: {
target: "esnext",
strict: true,
},
});
});

it("overwrites arrays on merge", () => {
cfg.merge({
include: ["src/**/*.ts"],
});

cfg.merge({
include: ["tests/**/*.ts"],
});

expect(cfg.get("include")).toEqual(["tests/**/*.ts"]);
});

it("raw() behaves like merge()", () => {
cfg.raw({
compilerOptions: {
module: "commonjs",
},
});

expect(cfg.get("compilerOptions.module")).toBe("commonjs");
});

it("from(initial) clones the initial object (no shared references)", () => {
const initial = {compilerOptions: {target: "es5"}} as any;
const local = ConfigBuilder.from(initial);

(initial as any).compilerOptions.target = "es3";
expect(local.get("compilerOptions.target")).toBe("es5");

local.set("compilerOptions.module", "commonjs");
expect((initial as any).compilerOptions.module).toBeUndefined();
});

it("get() returns a deep-cloned read-only snapshot", () => {
cfg.set("compilerOptions.target", "esnext");

const snapshot = cfg.get();

expect(snapshot).toEqual({
compilerOptions: {
target: "esnext",
},
});
});

it("mutating the snapshot does not affect internal state", () => {
cfg.set("compilerOptions.target", "esnext");

const snapshot = cfg.get();

try {
(snapshot as any).compilerOptions.target = "es5";
} catch {}

expect(cfg.get("compilerOptions.target")).toBe("esnext");
});

it("toJSON returns a JSON string of the current config", () => {
cfg.set("compilerOptions.target", "esnext");

const json = cfg.toJSON();

expect(typeof json).toBe("string");
expect(json).toBe(
JSON.stringify({
compilerOptions: {target: "esnext"},
})
);
});

it("set overwrites a primitive value with an object when needed", () => {
cfg.set("compilerOptions", {} as any);
cfg.set("compilerOptions.strict", true);

expect(cfg.get("compilerOptions.strict")).toBe(true);
});

it("delete removes only the targeted leaf, preserving siblings", () => {
cfg.set("compilerOptions.target", "esnext");
cfg.set("compilerOptions.strict", true);

cfg.delete("compilerOptions.target");

expect(cfg.get()).toEqual({
compilerOptions: {
strict: true,
},
});
});

it("merge does not remove existing keys (idempotent addition)", () => {
cfg.set("compilerOptions.target", "esnext");

cfg.merge({
compilerOptions: {
strict: true,
},
});

expect(cfg.get("compilerOptions.target")).toBe("esnext");
});
});
88 changes: 88 additions & 0 deletions src/cli/builders/typescript/ConfigBuilder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import _ from "lodash";

import {isPlainObject, mergeDeep} from "./utils";

import type {PartialDeep, TsConfigJson} from "type-fest";

import type {DotPath, PathValue, TsConfigBuilder} from "@typing/typescript";

export class ConfigBuilder implements TsConfigBuilder {
private readonly state?: TsConfigJson;

private constructor(initial?: TsConfigJson) {
this.state = mergeDeep({}, initial || {});
}

static from(initial?: TsConfigJson): TsConfigBuilder {
return new ConfigBuilder(initial);
}

public get(): TsConfigJson;
public get<P extends DotPath<TsConfigJson>>(path: P): PathValue<TsConfigJson, P> | undefined;
public get(path?: string) {
if (!path) {
return _.cloneDeep(this.state);
}

const segments = path.split(".");
let current: any = this.state;

for (const segment of segments) {
if (!current || typeof current !== "object") {
return undefined;
}
current = current[segment];
}

return current;
}

public set<P extends DotPath<TsConfigJson>>(path: P, value: PathValue<TsConfigJson, P>) {
const segments = path.split(".");
let current: any = this.state;

while (segments.length > 1) {
const segment = segments.shift()!;
if (!isPlainObject(current[segment])) {
current[segment] = {};
}
current = current[segment];
}

current[segments[0]] = value;
return this;
}

public has(path: DotPath<TsConfigJson>): boolean {
return this.get(path) !== undefined;
}

public delete(path: DotPath<TsConfigJson>) {
const segments = path.split(".");
let current: any = this.state;

while (segments.length > 1) {
const segment = segments.shift()!;
if (!isPlainObject(current[segment])) {
return this;
}
current = current[segment];
}

delete current[segments[0]];
return this;
}

public merge(config: PartialDeep<TsConfigJson>) {
return this.raw(config);
}

public raw(config: PartialDeep<TsConfigJson>) {
mergeDeep(this.state || {}, config);
return this;
}

public toJSON(): string {
return JSON.stringify(this.get());
}
}
1 change: 1 addition & 0 deletions src/cli/builders/typescript/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export {ConfigBuilder} from "./ConfigBuilder";
18 changes: 18 additions & 0 deletions src/cli/builders/typescript/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import _ from "lodash";

import type {PartialDeep} from "type-fest";

export function isPlainObject(value: unknown): value is Record<string, unknown> {
if (typeof value !== "object" || value === null) return false;

const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
}

export function mergeDeep<T extends object>(target: T, source: PartialDeep<T>): T {
return _.mergeWith(target, source, (_objValue, srcValue) => {
if (Array.isArray(srcValue)) {
return srcValue;
}
});
}
21 changes: 20 additions & 1 deletion src/cli/plugins/typescript/TypescriptConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {ReadonlyConfig} from "@typing/config";
import {PackageName} from "@typing/app";

export default class extends FileBuilder {
protected tsConfig: TsConfigJson = {};

protected readonly vendorAliases = {
[`${PackageName}/browser`]: "@addon-core/browser",
[`${PackageName}/storage`]: "@addon-core/storage",
Expand All @@ -32,7 +34,13 @@ export default class extends FileBuilder {
}

protected content(): string {
return JSON.stringify(this.json(), null, 2);
const json = _.mergeWith(this.tsConfig, this.json(), (_objValue, srcValue) => {
if (Array.isArray(srcValue)) {
return srcValue;
}
});

return JSON.stringify(json, null, 2);
}

protected alias(): Record<string, string> {
Expand Down Expand Up @@ -86,4 +94,15 @@ export default class extends FileBuilder {
exclude: [outputDir],
};
}

public merge(config?: TsConfigJson) {
if (config) {
this.tsConfig = _.mergeWith(this.tsConfig, config, (_objValue, srcValue) => {
if (Array.isArray(srcValue)) {
return srcValue;
}
});
}
return this;
}
}
13 changes: 12 additions & 1 deletion src/cli/plugins/typescript/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import {Configuration as RspackConfig} from "@rspack/core";

import {definePlugin} from "@main/plugin";

import {ConfigBuilder} from "@cli/builders/typescript";

import TypescriptConfig from "./TypescriptConfig";

import {TransportDeclaration, TransportDeclarationLayer, VendorDeclaration} from "./declaration";
Expand All @@ -15,7 +17,16 @@ export default definePlugin(() => {
return {
name: "adnbn:typescript",
startup: ({config}) => {
typescript = TypescriptConfig.make(config);
const {tsConfig} = config;
const configBuilder = ConfigBuilder.from();

if (typeof tsConfig === "function") {
tsConfig(configBuilder);
} else if (typeof tsConfig === "object") {
configBuilder.raw(tsConfig);
}

typescript = new TypescriptConfig(config).merge(configBuilder.get()).build();

VendorDeclaration.make(config);
},
Expand Down
2 changes: 2 additions & 0 deletions src/cli/resolvers/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ export default async (config: OptionalConfig): Promise<Config> => {
env = {},
manifest,
manifestVersion = (new Set<Browser>([Browser.Safari]).has(browser) ? 2 : 3) as ManifestVersion,
tsConfig,
mode = Mode.Development,
analyze = false,
plugins = [],
Expand Down Expand Up @@ -250,6 +251,7 @@ export default async (config: OptionalConfig): Promise<Config> => {
specific,
manifest,
manifestVersion,
tsConfig,
rootDir,
outDir,
srcDir,
Expand Down
Loading