From 47e1a5ca6a975282a0c1f1907e436b32a96baeeb Mon Sep 17 00:00:00 2001 From: Adam Jones Date: Thu, 14 Dec 2023 14:33:20 +0000 Subject: [PATCH] Add support for extends as array of strings to v3 (backport of #245) (#260) TypeScript 5.0 added support for defining "extends" as an array of strings. This commit adds support for this use case. --- src/__tests__/tsconfig-loader.test.ts | 116 ++++++++++++++++++++-- src/tsconfig-loader.ts | 132 ++++++++++++++++++-------- 2 files changed, 203 insertions(+), 45 deletions(-) diff --git a/src/__tests__/tsconfig-loader.test.ts b/src/__tests__/tsconfig-loader.test.ts index 7394482..29a8792 100644 --- a/src/__tests__/tsconfig-loader.test.ts +++ b/src/__tests__/tsconfig-loader.test.ts @@ -133,7 +133,7 @@ describe("walkForTsConfig", () => { }); describe("loadConfig", () => { - it("It should load a config", () => { + it("should load a config", () => { const config = { compilerOptions: { baseUrl: "hej" } }; const res = loadTsconfig( "/root/dir1/tsconfig.json", @@ -144,7 +144,7 @@ describe("loadConfig", () => { expect(res).toStrictEqual(config); }); - it("It should load a config with comments", () => { + it("should load a config with comments", () => { const config = { compilerOptions: { baseUrl: "hej" } }; const res = loadTsconfig( "/root/dir1/tsconfig.json", @@ -160,7 +160,7 @@ describe("loadConfig", () => { expect(res).toStrictEqual(config); }); - it("It should load a config with trailing commas", () => { + it("should load a config with trailing commas", () => { const config = { compilerOptions: { baseUrl: "hej" } }; const res = loadTsconfig( "/root/dir1/tsconfig.json", @@ -175,7 +175,21 @@ describe("loadConfig", () => { expect(res).toStrictEqual(config); }); - it("It should load a config with extends and overwrite all options", () => { + it("should throw an error including the file path when encountering invalid JSON5", () => { + expect(() => + loadTsconfig( + "/root/dir1/tsconfig.json", + (path) => path === "/root/dir1/tsconfig.json", + (_) => `{ + "compilerOptions": { + }` + ) + ).toThrowError( + "/root/dir1/tsconfig.json is malformed JSON5: invalid end of input at 3:12" + ); + }); + + it("should load a config with string extends and overwrite all options", () => { const firstConfig = { extends: "../base-config.json", compilerOptions: { baseUrl: "kalle", paths: { foo: ["bar2"] } }, @@ -221,7 +235,7 @@ describe("loadConfig", () => { }); }); - it("It should load a config with extends from node_modules and overwrite all options", () => { + it("should load a config with string extends from node_modules and overwrite all options", () => { const firstConfig = { extends: "my-package/base-config.json", compilerOptions: { baseUrl: "kalle", paths: { foo: ["bar2"] } }, @@ -273,7 +287,7 @@ describe("loadConfig", () => { }); }); - it("Should use baseUrl relative to location of extended tsconfig", () => { + it("should use baseUrl relative to location of extended tsconfig", () => { const firstConfig = { compilerOptions: { baseUrl: "." } }; const firstConfigPath = join("/root", "first-config.json"); const secondConfig = { extends: "../first-config.json" }; @@ -309,4 +323,94 @@ describe("loadConfig", () => { compilerOptions: { baseUrl: join("..", "..") }, }); }); + + it("should load a config with array extends and overwrite all options", () => { + const baseConfig1 = { + compilerOptions: { baseUrl: ".", paths: { foo: ["bar"] } }, + }; + const baseConfig1Path = join("/root", "base-config-1.json"); + const baseConfig2 = { compilerOptions: { baseUrl: "." } }; + const baseConfig2Path = join("/root", "dir1", "base-config-2.json"); + const baseConfig3 = { + compilerOptions: { baseUrl: ".", paths: { foo: ["bar2"] } }, + }; + const baseConfig3Path = join("/root", "dir1", "dir2", "base-config-3.json"); + const actualConfig = { + extends: [ + "./base-config-1.json", + "./dir1/base-config-2.json", + "./dir1/dir2/base-config-3.json", + ], + }; + const actualConfigPath = join("/root", "tsconfig.json"); + + const res = loadTsconfig( + join("/root", "tsconfig.json"), + (path) => + [ + baseConfig1Path, + baseConfig2Path, + baseConfig3Path, + actualConfigPath, + ].indexOf(path) >= 0, + (path) => { + if (path === baseConfig1Path) { + return JSON.stringify(baseConfig1); + } + if (path === baseConfig2Path) { + return JSON.stringify(baseConfig2); + } + if (path === baseConfig3Path) { + return JSON.stringify(baseConfig3); + } + if (path === actualConfigPath) { + return JSON.stringify(actualConfig); + } + return ""; + } + ); + + expect(res).toEqual({ + extends: [ + "./base-config-1.json", + "./dir1/base-config-2.json", + "./dir1/dir2/base-config-3.json", + ], + compilerOptions: { + baseUrl: join("dir1", "dir2"), + paths: { foo: ["bar2"] }, + }, + }); + }); + + it("should load a config with array extends without .json extension", () => { + const baseConfig = { + compilerOptions: { baseUrl: ".", paths: { foo: ["bar"] } }, + }; + const baseConfigPath = join("/root", "base-config-1.json"); + const actualConfig = { extends: ["./base-config-1"] }; + const actualConfigPath = join("/root", "tsconfig.json"); + + const res = loadTsconfig( + join("/root", "tsconfig.json"), + (path) => [baseConfigPath, actualConfigPath].indexOf(path) >= 0, + (path) => { + if (path === baseConfigPath) { + return JSON.stringify(baseConfig); + } + if (path === actualConfigPath) { + return JSON.stringify(actualConfig); + } + return ""; + } + ); + + expect(res).toEqual({ + extends: ["./base-config-1"], + compilerOptions: { + baseUrl: ".", + paths: { foo: ["bar"] }, + }, + }); + }); }); diff --git a/src/tsconfig-loader.ts b/src/tsconfig-loader.ts index c2dfb04..9ece441 100644 --- a/src/tsconfig-loader.ts +++ b/src/tsconfig-loader.ts @@ -9,7 +9,7 @@ import StripBom = require("strip-bom"); * Typing for the parts of tsconfig that we care about */ export interface Tsconfig { - extends?: string; + extends?: string | string[]; compilerOptions?: { baseUrl?: string; paths?: { [key: string]: Array }; @@ -122,51 +122,105 @@ export function loadTsconfig( const configString = readFileSync(configFilePath); const cleanedJson = StripBom(configString); - const config: Tsconfig = JSON5.parse(cleanedJson); - let extendedConfig = config.extends; + let config: Tsconfig; + try { + config = JSON5.parse(cleanedJson); + } catch (e) { + throw new Error(`${configFilePath} is malformed ${e.message}`); + } + let extendedConfig = config.extends; if (extendedConfig) { - if ( - typeof extendedConfig === "string" && - extendedConfig.indexOf(".json") === -1 - ) { - extendedConfig += ".json"; - } - const currentDir = path.dirname(configFilePath); - let extendedConfigPath = path.join(currentDir, extendedConfig); - if ( - extendedConfig.indexOf("/") !== -1 && - extendedConfig.indexOf(".") !== -1 && - !existsSync(extendedConfigPath) - ) { - extendedConfigPath = path.join( - currentDir, - "node_modules", - extendedConfig + let base: Tsconfig; + + if (Array.isArray(extendedConfig)) { + base = extendedConfig.reduce( + (currBase, extendedConfigElement) => + mergeTsconfigs( + currBase, + loadTsconfigFromExtends( + configFilePath, + extendedConfigElement, + existsSync, + readFileSync + ) + ), + {} + ); + } else { + base = loadTsconfigFromExtends( + configFilePath, + extendedConfig, + existsSync, + readFileSync ); } - const base = - loadTsconfig(extendedConfigPath, existsSync, readFileSync) || {}; + return mergeTsconfigs(base, config); + } + return config; +} - // baseUrl should be interpreted as relative to the base tsconfig, - // but we need to update it so it is relative to the original tsconfig being loaded - if (base.compilerOptions && base.compilerOptions.baseUrl) { - const extendsDir = path.dirname(extendedConfig); - base.compilerOptions.baseUrl = path.join( - extendsDir, - base.compilerOptions.baseUrl - ); - } +/** + * Intended to be called only from loadTsconfig. + * Parameters don't have defaults because they should use the same as loadTsconfig. + */ +function loadTsconfigFromExtends( + configFilePath: string, + extendedConfigValue: string, + // eslint-disable-next-line no-shadow + existsSync: (path: string) => boolean, + readFileSync: (filename: string) => string +): Tsconfig { + if ( + typeof extendedConfigValue === "string" && + extendedConfigValue.indexOf(".json") === -1 + ) { + extendedConfigValue += ".json"; + } + const currentDir = path.dirname(configFilePath); + let extendedConfigPath = path.join(currentDir, extendedConfigValue); + if ( + extendedConfigValue.indexOf("/") !== -1 && + extendedConfigValue.indexOf(".") !== -1 && + !existsSync(extendedConfigPath) + ) { + extendedConfigPath = path.join( + currentDir, + "node_modules", + extendedConfigValue + ); + } - return { - ...base, - ...config, - compilerOptions: { - ...base.compilerOptions, - ...config.compilerOptions, - }, - }; + const config = + loadTsconfig(extendedConfigPath, existsSync, readFileSync) || {}; + + // baseUrl should be interpreted as relative to extendedConfigPath, + // but we need to update it so it is relative to the original tsconfig being loaded + if (config.compilerOptions?.baseUrl) { + const extendsDir = path.dirname(extendedConfigValue); + config.compilerOptions.baseUrl = path.join( + extendsDir, + config.compilerOptions.baseUrl + ); } + return config; } + +function mergeTsconfigs( + base: Tsconfig | undefined, + config: Tsconfig | undefined +): Tsconfig { + base = base || {}; + config = config || {}; + + return { + ...base, + ...config, + compilerOptions: { + ...base.compilerOptions, + ...config.compilerOptions, + }, + }; +}