Skip to content

Commit

Permalink
Add support for extends as array of strings. (#245)
Browse files Browse the repository at this point in the history
* Add support for extends as array of strings.

TypeScript 5.0 added support for defining "extends" as an array of
strings. This commit adds support for this use case.

It's important to note that even with this change, "baseUrl" and
"paths" are still always being completely overwritten if a later
tsconfig redefines any of those values.
This might be confusing because a tsconfig may define
"baseUrl=value1" and its own set of "paths" based on that baseUrl,
but if a later tsconfig defines its own "baseUrl=value2", the
overall config ends up becoming "baseUrl=value2" with the "paths"
from the first config.

This behaviour hasn't changed even when "extends" is an array of
strings, so this commit maintains this behaviour.

* Add test for array extends without .json extension.
  • Loading branch information
DanielSidhion committed Mar 29, 2023
1 parent 910a138 commit 5156ef1
Show file tree
Hide file tree
Showing 2 changed files with 184 additions and 45 deletions.
104 changes: 97 additions & 7 deletions src/__tests__/tsconfig-loader.test.ts
Expand Up @@ -168,7 +168,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",
Expand All @@ -178,7 +178,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",
Expand All @@ -193,7 +193,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",
Expand All @@ -207,7 +207,7 @@ describe("loadConfig", () => {
expect(res).toStrictEqual(config);
});

it("It should throw an error including the file path when encountering invalid JSON5", () => {
it("should throw an error including the file path when encountering invalid JSON5", () => {
expect(() =>
loadTsconfig(
"/root/dir1/tsconfig.json",
Expand All @@ -221,7 +221,7 @@ describe("loadConfig", () => {
);
});

it("It should load a config with extends and overwrite all options", () => {
it("should load a config with string extends and overwrite all options", () => {
const firstConfig = {
extends: "../base-config.json",
compilerOptions: { baseUrl: "kalle", paths: { foo: ["bar2"] } },
Expand Down Expand Up @@ -259,7 +259,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"] } },
Expand Down Expand Up @@ -303,7 +303,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" };
Expand Down Expand Up @@ -335,4 +335,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"] },
},
});
});
});
125 changes: 87 additions & 38 deletions src/tsconfig-loader.ts
Expand Up @@ -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<string> };
Expand Down Expand Up @@ -131,50 +131,99 @@ export function loadTsconfig(
} catch (e) {
throw new Error(`${configFilePath} is malformed ${e.message}`);
}
let extendedConfig = config.extends;

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,
},
};
}

0 comments on commit 5156ef1

Please sign in to comment.