From c0a14497ee9fe0050c27d78b281f74341c6f2b30 Mon Sep 17 00:00:00 2001 From: feng Date: Sat, 26 Mar 2022 23:12:54 +0800 Subject: [PATCH] feat: support omit `tsconfig.json` prefix when extends package --- src/__tests__/tsconfig-loader.test.ts | 179 +++++++++++++++++++++++++- src/tsconfig-loader.ts | 163 +++++++++++++++++------ 2 files changed, 295 insertions(+), 47 deletions(-) diff --git a/src/__tests__/tsconfig-loader.test.ts b/src/__tests__/tsconfig-loader.test.ts index 7394482..1d0db19 100644 --- a/src/__tests__/tsconfig-loader.test.ts +++ b/src/__tests__/tsconfig-loader.test.ts @@ -134,7 +134,7 @@ describe("walkForTsConfig", () => { describe("loadConfig", () => { it("It should load a config", () => { - const config = { compilerOptions: { baseUrl: "hej" } }; + const config = { compilerOptions: { baseUrl: "/root/dir1/hej" } }; const res = loadTsconfig( "/root/dir1/tsconfig.json", (path) => path === "/root/dir1/tsconfig.json", @@ -145,7 +145,7 @@ describe("loadConfig", () => { }); it("It should load a config with comments", () => { - const config = { compilerOptions: { baseUrl: "hej" } }; + const config = { compilerOptions: { baseUrl: "/root/dir1/hej" } }; const res = loadTsconfig( "/root/dir1/tsconfig.json", (path) => path === "/root/dir1/tsconfig.json", @@ -161,7 +161,7 @@ describe("loadConfig", () => { }); it("It should load a config with trailing commas", () => { - const config = { compilerOptions: { baseUrl: "hej" } }; + const config = { compilerOptions: { baseUrl: "/root/dir1/hej" } }; const res = loadTsconfig( "/root/dir1/tsconfig.json", (path) => path === "/root/dir1/tsconfig.json", @@ -214,7 +214,7 @@ describe("loadConfig", () => { expect(res).toEqual({ extends: "../base-config.json", compilerOptions: { - baseUrl: "kalle", + baseUrl: "/root/dir1/kalle", paths: { foo: ["bar2"] }, strict: true, }, @@ -266,13 +266,180 @@ describe("loadConfig", () => { expect(res).toEqual({ extends: "my-package/base-config.json", compilerOptions: { - baseUrl: "kalle", + baseUrl: "/root/dir1/kalle", paths: { foo: ["bar2"] }, strict: true, }, }); }); + it("extends package when node_modules is a level upper to tsconfig.json", () => { + const firstConfig = { + extends: "@package/foo", + }; + const firstConfigPath = join("/root", "project", "src", "tsconfig.json"); + const baseConfig = { + compilerOptions: { + baseUrl: "olle", + paths: { foo: ["bar1"] }, + strict: true, + }, + }; + const baseConfigPath = join( + "/root", + "project", + "node_modules", + "@package/foo", + "tsconfig.json" + ); + const res = loadTsconfig( + join("/root", "project", "src", "tsconfig.json"), + (path) => path === firstConfigPath || path === baseConfigPath, + (path) => { + if (path === firstConfigPath) { + return JSON.stringify(firstConfig); + } + if (path === baseConfigPath) { + return JSON.stringify(baseConfig); + } + return ""; + } + ); + + expect(res).toEqual({ + extends: "@package/foo", + compilerOptions: { + baseUrl: "/root/project/node_modules/@package/foo/olle", + paths: { foo: ["bar1"] }, + strict: true, + }, + }); + }); + + it("omit `tsconfig.json` prefix when extends package", () => { + const firstConfig = { + extends: "my-package", + compilerOptions: { baseUrl: "kalle", paths: { foo: ["bar2"] } }, + }; + const firstConfigPath = join("/root", "dir1", "tsconfig.json"); + const baseConfig = { + compilerOptions: { + baseUrl: "olle", + paths: { foo: ["bar1"] }, + strict: true, + }, + }; + const baseConfigPath = join( + "/root", + "dir1", + "node_modules", + "my-package", + "tsconfig.json" + ); + const res = loadTsconfig( + join("/root", "dir1", "tsconfig.json"), + (path) => path === firstConfigPath || path === baseConfigPath, + (path) => { + if (path === firstConfigPath) { + return JSON.stringify(firstConfig); + } + if (path === baseConfigPath) { + return JSON.stringify(baseConfig); + } + return ""; + } + ); + + expect(res).toEqual({ + extends: "my-package", + compilerOptions: { + baseUrl: "/root/dir1/kalle", + paths: { foo: ["bar2"] }, + strict: true, + }, + }); + }); + + it("omit `.json` extension prefix when extends package", () => { + const firstConfig = { + extends: "my-package/base-config", + compilerOptions: { baseUrl: "kalle", paths: { foo: ["bar2"] } }, + }; + const firstConfigPath = join("/root", "dir1", "tsconfig.json"); + const baseConfig = { + compilerOptions: { + baseUrl: "olle", + paths: { foo: ["bar1"] }, + strict: true, + }, + }; + const baseConfigPath = join( + "/root", + "dir1", + "node_modules", + "my-package", + "base-config.json" + ); + const res = loadTsconfig( + join("/root", "dir1", "tsconfig.json"), + (path) => path === firstConfigPath || path === baseConfigPath, + (path) => { + if (path === firstConfigPath) { + return JSON.stringify(firstConfig); + } + if (path === baseConfigPath) { + return JSON.stringify(baseConfig); + } + return ""; + } + ); + + expect(res).toEqual({ + extends: "my-package/base-config", + compilerOptions: { + baseUrl: "/root/dir1/kalle", + paths: { foo: ["bar2"] }, + strict: true, + }, + }); + }); + + it("should stop resolve base config when detect an extends loop", () => { + const firstConfig = { + extends: "./base-config.json", + compilerOptions: { baseUrl: "kalle" }, + }; + const firstConfigPath = join("/root", "dir1", "tsconfig.json"); + const baseConfig = { + extends: "./tsconfig.json", + compilerOptions: { + paths: { foo: ["bar1"] }, + }, + }; + const baseConfigPath = join("/root", "dir1", "base-config.json"); + const res = loadTsconfig( + join("/root", "dir1", "tsconfig.json"), + (path) => path === firstConfigPath || path === baseConfigPath, + (path) => { + if (path === firstConfigPath) { + return JSON.stringify(firstConfig); + } + if (path === baseConfigPath) { + return JSON.stringify(baseConfig); + } + return ""; + } + ); + + expect(res).toEqual({ + extends: "./base-config.json", + compilerOptions: { + baseUrl: "/root/dir1/kalle", + paths: { foo: ["bar1"] }, + }, + }); + }); + it("Should use baseUrl relative to location of extended tsconfig", () => { const firstConfig = { compilerOptions: { baseUrl: "." } }; const firstConfigPath = join("/root", "first-config.json"); @@ -306,7 +473,7 @@ describe("loadConfig", () => { // }); expect(res).toEqual({ extends: "../second-config.json", - compilerOptions: { baseUrl: join("..", "..") }, + compilerOptions: { baseUrl: "/root" }, }); }); }); diff --git a/src/tsconfig-loader.ts b/src/tsconfig-loader.ts index c2dfb04..8329fc8 100644 --- a/src/tsconfig-loader.ts +++ b/src/tsconfig-loader.ts @@ -114,8 +114,15 @@ export function loadTsconfig( configFilePath: string, existsSync: (path: string) => boolean = fs.existsSync, readFileSync: (filename: string) => string = (filename: string) => - fs.readFileSync(filename, "utf8") + fs.readFileSync(filename, "utf8"), + visited: Record = {} ): Tsconfig | undefined { + // Don't infinite loop if a series of "extends" links forms a cycle + if (visited[configFilePath]) { + return undefined; + } + visited[configFilePath] = true; + if (!existsSync(configFilePath)) { return undefined; } @@ -123,50 +130,124 @@ export function loadTsconfig( const configString = readFileSync(configFilePath); const cleanedJson = StripBom(configString); const config: Tsconfig = JSON5.parse(cleanedJson); - let extendedConfig = config.extends; - - if (extendedConfig) { - if ( - typeof extendedConfig === "string" && - extendedConfig.indexOf(".json") === -1 - ) { - extendedConfig += ".json"; + + const base = getExtendedConfig( + config, + configFilePath, + visited, + existsSync, + readFileSync + ); + + if ( + config.compilerOptions?.baseUrl && + !path.isAbsolute(config.compilerOptions.baseUrl) + ) { + const fileDir = path.dirname(configFilePath); + config.compilerOptions.baseUrl = path.join( + fileDir, + config.compilerOptions.baseUrl + ); + } + + return { + ...base, + ...config, + compilerOptions: { + ...base?.compilerOptions, + ...config.compilerOptions, + }, + }; +} + +function getExtendedConfig( + sourceConfig: Tsconfig, + sourceConfigFilePath: string, + visited: Record, + existsSync: (path: string) => boolean, + readFileSync: (filename: string) => string +): Tsconfig | undefined { + let extendedConfig = sourceConfig.extends; + if (!extendedConfig) { + return undefined; + } + + const extendedConfigPath = getExtendedConfigPath( + extendedConfig, + sourceConfigFilePath, + existsSync + ); + if (!extendedConfigPath) { + return undefined; + } + + return loadTsconfig(extendedConfigPath, existsSync, readFileSync, visited); +} + +function getExtendedConfigPath( + extendedConfig: string, + sourceConfigFilePath: string, + existsSync: (path: string) => boolean +): string | undefined { + const currentDir = path.dirname(sourceConfigFilePath); + // If this is a package path, try to resolve it to a "node_modules" folder. + if (isPackagePath(extendedConfig)) { + return forEachAncestorDirectory(currentDir, (ancestor) => { + // Skip "node_modules" folders + if (path.basename(ancestor) !== "node_modules") { + const extendedPackage = path.join( + ancestor, + "node_modules", + extendedConfig + ); + const fileToCheck = [ + extendedPackage, + path.join(extendedPackage, "tsconfig.json"), + `${extendedPackage}.json`, + ]; + return fileToCheck.find(existsSync); + } + return undefined; + }); + } else { + // If this is a regular path, search relative to the enclosing directory + let extendedConfigPath = extendedConfig; + if (!path.isAbsolute(extendedConfig)) { + extendedConfigPath = path.join(currentDir, extendedConfig); } - 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 - ); + if (extendedConfigPath.indexOf(".json") === -1) { + extendedConfigPath += ".json"; } + return extendedConfigPath; + } +} - const base = - loadTsconfig(extendedConfigPath, existsSync, readFileSync) || {}; - - // 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 - ); +/** + * Calls `callback` on `directory` and every ancestor directory it has, returning the first defined result. + */ +export function forEachAncestorDirectory( + directory: string, + callback: (directory: string) => T | undefined +): T | undefined { + while (true) { + const result = callback(directory); + if (result !== undefined) { + return result; } - return { - ...base, - ...config, - compilerOptions: { - ...base.compilerOptions, - ...config.compilerOptions, - }, - }; + const parentPath = path.dirname(directory); + if (parentPath === directory) { + return undefined; + } + + directory = parentPath; } - return config; +} + +// Package paths are loaded from a "node_modules" directory. Non-package paths +// are relative or absolute paths. +function isPackagePath(dir: string): boolean { + return ( + !dir.startsWith("/") && !dir.startsWith("./") && !dir.startsWith("../") + ); }