Skip to content

Commit

Permalink
Add support for extends as array of strings to v3 (backport of #245) (#…
Browse files Browse the repository at this point in the history
…260)

TypeScript 5.0 added support for defining "extends" as an array of
strings. This commit adds support for this use case.
  • Loading branch information
domdomegg committed Dec 14, 2023
1 parent a1d731e commit 47e1a5c
Show file tree
Hide file tree
Showing 2 changed files with 203 additions and 45 deletions.
116 changes: 110 additions & 6 deletions src/__tests__/tsconfig-loader.test.ts
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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"] } },
Expand Down Expand Up @@ -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"] } },
Expand Down Expand Up @@ -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" };
Expand Down Expand Up @@ -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"] },
},
});
});
});
132 changes: 93 additions & 39 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 @@ -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,
},
};
}

0 comments on commit 47e1a5c

Please sign in to comment.