Skip to content

Commit

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

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 5, 2023
1 parent a1d731e commit ecdaedc
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 ecdaedc

Please sign in to comment.