diff --git a/package.json b/package.json index f330ae7..4e75e06 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/cli/builders/typescript/ConfigBuilder.test.ts b/src/cli/builders/typescript/ConfigBuilder.test.ts new file mode 100644 index 0000000..0548181 --- /dev/null +++ b/src/cli/builders/typescript/ConfigBuilder.test.ts @@ -0,0 +1,188 @@ +import {ConfigBuilder} from "./ConfigBuilder"; + +describe("TypeScript ConfigBuilder", () => { + let cfg: ReturnType; + + 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"); + }); +}); diff --git a/src/cli/builders/typescript/ConfigBuilder.ts b/src/cli/builders/typescript/ConfigBuilder.ts new file mode 100644 index 0000000..f7939cb --- /dev/null +++ b/src/cli/builders/typescript/ConfigBuilder.ts @@ -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

>(path: P): PathValue | 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

>(path: P, value: PathValue) { + 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): boolean { + return this.get(path) !== undefined; + } + + public delete(path: DotPath) { + 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) { + return this.raw(config); + } + + public raw(config: PartialDeep) { + mergeDeep(this.state || {}, config); + return this; + } + + public toJSON(): string { + return JSON.stringify(this.get()); + } +} diff --git a/src/cli/builders/typescript/index.ts b/src/cli/builders/typescript/index.ts new file mode 100644 index 0000000..4097ad9 --- /dev/null +++ b/src/cli/builders/typescript/index.ts @@ -0,0 +1 @@ +export {ConfigBuilder} from "./ConfigBuilder"; diff --git a/src/cli/builders/typescript/utils.ts b/src/cli/builders/typescript/utils.ts new file mode 100644 index 0000000..59cb73a --- /dev/null +++ b/src/cli/builders/typescript/utils.ts @@ -0,0 +1,18 @@ +import _ from "lodash"; + +import type {PartialDeep} from "type-fest"; + +export function isPlainObject(value: unknown): value is Record { + if (typeof value !== "object" || value === null) return false; + + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +export function mergeDeep(target: T, source: PartialDeep): T { + return _.mergeWith(target, source, (_objValue, srcValue) => { + if (Array.isArray(srcValue)) { + return srcValue; + } + }); +} diff --git a/src/cli/plugins/typescript/TypescriptConfig.ts b/src/cli/plugins/typescript/TypescriptConfig.ts index 3ea3c2a..78e5226 100644 --- a/src/cli/plugins/typescript/TypescriptConfig.ts +++ b/src/cli/plugins/typescript/TypescriptConfig.ts @@ -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", @@ -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 { @@ -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; + } } diff --git a/src/cli/plugins/typescript/index.ts b/src/cli/plugins/typescript/index.ts index b8d940b..619a69b 100644 --- a/src/cli/plugins/typescript/index.ts +++ b/src/cli/plugins/typescript/index.ts @@ -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"; @@ -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); }, diff --git a/src/cli/resolvers/config.ts b/src/cli/resolvers/config.ts index d2800b7..555b4ac 100644 --- a/src/cli/resolvers/config.ts +++ b/src/cli/resolvers/config.ts @@ -196,6 +196,7 @@ export default async (config: OptionalConfig): Promise => { env = {}, manifest, manifestVersion = (new Set([Browser.Safari]).has(browser) ? 2 : 3) as ManifestVersion, + tsConfig, mode = Mode.Development, analyze = false, plugins = [], @@ -250,6 +251,7 @@ export default async (config: OptionalConfig): Promise => { specific, manifest, manifestVersion, + tsConfig, rootDir, outDir, srcDir, diff --git a/src/types/config.ts b/src/types/config.ts index ada3e2a..0e566b9 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -1,5 +1,6 @@ import type {Configuration as RspackConfig, Filename} from "@rspack/core"; import type {Options as HtmlOptions} from "html-rspack-tags-plugin"; +import type {TsConfigJson} from "type-fest"; import {Command, Mode} from "@typing/app"; import {Browser, BrowserSpecific} from "@typing/browser"; @@ -7,6 +8,7 @@ import {ManifestIncognitoValue, ManifestVersion, ManifestBuilder, OptionalManife import {Plugin} from "@typing/plugin"; import {Language} from "@typing/locale"; import {Awaiter} from "@typing/helpers"; +import {TsConfigBuilder} from "@typing/typescript"; import {EnvFilterOptions, EnvFilterVariant} from "@typing/env"; /** @@ -191,6 +193,11 @@ export interface Config { */ manifestVersion: ManifestVersion; + /** + * TypeScript configuration for the project. This is the parsed tsconfig.json content. + */ + tsConfig?: TsConfigJson | ((builder: TsConfigBuilder) => void); + /** * Default locale for the extension. * @example "en" diff --git a/src/types/typescript.ts b/src/types/typescript.ts new file mode 100644 index 0000000..8d0b35e --- /dev/null +++ b/src/types/typescript.ts @@ -0,0 +1,29 @@ +import type {Get, PartialDeep, TsConfigJson} from "type-fest"; + +export type DotPath = T extends object + ? { + [K in Extract]: NonNullable extends object + ? K | `${K}.${DotPath>}` + : K; + }[Extract] + : never; + +export type PathValue = Get; + +export interface TsConfigBuilder { + get

>(path: P): PathValue | undefined; + + get(): TsConfigJson; + + set

>(path: P, value: PathValue): this; + + has(path: DotPath): boolean; + + delete(path: DotPath): this; + + merge(config: PartialDeep): this; + + raw(config: PartialDeep): this; + + toJSON(): string; +}