Skip to content

Commit 540a1c1

Browse files
committed
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.
1 parent 910a138 commit 540a1c1

2 files changed

Lines changed: 153 additions & 45 deletions

File tree

src/__tests__/tsconfig-loader.test.ts

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ describe("walkForTsConfig", () => {
168168
});
169169

170170
describe("loadConfig", () => {
171-
it("It should load a config", () => {
171+
it("should load a config", () => {
172172
const config = { compilerOptions: { baseUrl: "hej" } };
173173
const res = loadTsconfig(
174174
"/root/dir1/tsconfig.json",
@@ -178,7 +178,7 @@ describe("loadConfig", () => {
178178
expect(res).toStrictEqual(config);
179179
});
180180

181-
it("It should load a config with comments", () => {
181+
it("should load a config with comments", () => {
182182
const config = { compilerOptions: { baseUrl: "hej" } };
183183
const res = loadTsconfig(
184184
"/root/dir1/tsconfig.json",
@@ -193,7 +193,7 @@ describe("loadConfig", () => {
193193
expect(res).toStrictEqual(config);
194194
});
195195

196-
it("It should load a config with trailing commas", () => {
196+
it("should load a config with trailing commas", () => {
197197
const config = { compilerOptions: { baseUrl: "hej" } };
198198
const res = loadTsconfig(
199199
"/root/dir1/tsconfig.json",
@@ -207,7 +207,7 @@ describe("loadConfig", () => {
207207
expect(res).toStrictEqual(config);
208208
});
209209

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

224-
it("It should load a config with extends and overwrite all options", () => {
224+
it("should load a config with string extends and overwrite all options", () => {
225225
const firstConfig = {
226226
extends: "../base-config.json",
227227
compilerOptions: { baseUrl: "kalle", paths: { foo: ["bar2"] } },
@@ -259,7 +259,7 @@ describe("loadConfig", () => {
259259
});
260260
});
261261

262-
it("It should load a config with extends from node_modules and overwrite all options", () => {
262+
it("should load a config with string extends from node_modules and overwrite all options", () => {
263263
const firstConfig = {
264264
extends: "my-package/base-config.json",
265265
compilerOptions: { baseUrl: "kalle", paths: { foo: ["bar2"] } },
@@ -303,7 +303,7 @@ describe("loadConfig", () => {
303303
});
304304
});
305305

306-
it("Should use baseUrl relative to location of extended tsconfig", () => {
306+
it("should use baseUrl relative to location of extended tsconfig", () => {
307307
const firstConfig = { compilerOptions: { baseUrl: "." } };
308308
const firstConfigPath = join("/root", "first-config.json");
309309
const secondConfig = { extends: "../first-config.json" };
@@ -335,4 +335,63 @@ describe("loadConfig", () => {
335335
compilerOptions: { baseUrl: join("..", "..") },
336336
});
337337
});
338+
339+
it("should load a config with array extends and overwrite all options", () => {
340+
const baseConfig1 = {
341+
compilerOptions: { baseUrl: ".", paths: { foo: ["bar"] } },
342+
};
343+
const baseConfig1Path = join("/root", "base-config-1.json");
344+
const baseConfig2 = { compilerOptions: { baseUrl: "." } };
345+
const baseConfig2Path = join("/root", "dir1", "base-config-2.json");
346+
const baseConfig3 = {
347+
compilerOptions: { baseUrl: ".", paths: { foo: ["bar2"] } },
348+
};
349+
const baseConfig3Path = join("/root", "dir1", "dir2", "base-config-3.json");
350+
const actualConfig = {
351+
extends: [
352+
"./base-config-1.json",
353+
"./dir1/base-config-2.json",
354+
"./dir1/dir2/base-config-3.json",
355+
],
356+
};
357+
const actualConfigPath = join("/root", "tsconfig.json");
358+
359+
const res = loadTsconfig(
360+
join("/root", "tsconfig.json"),
361+
(path) =>
362+
[
363+
baseConfig1Path,
364+
baseConfig2Path,
365+
baseConfig3Path,
366+
actualConfigPath,
367+
].indexOf(path) >= 0,
368+
(path) => {
369+
if (path === baseConfig1Path) {
370+
return JSON.stringify(baseConfig1);
371+
}
372+
if (path === baseConfig2Path) {
373+
return JSON.stringify(baseConfig2);
374+
}
375+
if (path === baseConfig3Path) {
376+
return JSON.stringify(baseConfig3);
377+
}
378+
if (path === actualConfigPath) {
379+
return JSON.stringify(actualConfig);
380+
}
381+
return "";
382+
}
383+
);
384+
385+
expect(res).toEqual({
386+
extends: [
387+
"./base-config-1.json",
388+
"./dir1/base-config-2.json",
389+
"./dir1/dir2/base-config-3.json",
390+
],
391+
compilerOptions: {
392+
baseUrl: join("dir1", "dir2"),
393+
paths: { foo: ["bar2"] },
394+
},
395+
});
396+
});
338397
});

src/tsconfig-loader.ts

Lines changed: 87 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import StripBom = require("strip-bom");
99
* Typing for the parts of tsconfig that we care about
1010
*/
1111
export interface Tsconfig {
12-
extends?: string;
12+
extends?: string | string[];
1313
compilerOptions?: {
1414
baseUrl?: string;
1515
paths?: { [key: string]: Array<string> };
@@ -131,50 +131,99 @@ export function loadTsconfig(
131131
} catch (e) {
132132
throw new Error(`${configFilePath} is malformed ${e.message}`);
133133
}
134-
let extendedConfig = config.extends;
135134

135+
let extendedConfig = config.extends;
136136
if (extendedConfig) {
137-
if (
138-
typeof extendedConfig === "string" &&
139-
extendedConfig.indexOf(".json") === -1
140-
) {
141-
extendedConfig += ".json";
142-
}
143-
const currentDir = path.dirname(configFilePath);
144-
let extendedConfigPath = path.join(currentDir, extendedConfig);
145-
if (
146-
extendedConfig.indexOf("/") !== -1 &&
147-
extendedConfig.indexOf(".") !== -1 &&
148-
!existsSync(extendedConfigPath)
149-
) {
150-
extendedConfigPath = path.join(
151-
currentDir,
152-
"node_modules",
153-
extendedConfig
137+
let base: Tsconfig;
138+
139+
if (Array.isArray(extendedConfig)) {
140+
base = extendedConfig.reduce(
141+
(currBase, extendedConfigElement) =>
142+
mergeTsconfigs(
143+
currBase,
144+
loadTsconfigFromExtends(
145+
configFilePath,
146+
extendedConfigElement,
147+
existsSync,
148+
readFileSync
149+
)
150+
),
151+
{}
152+
);
153+
} else {
154+
base = loadTsconfigFromExtends(
155+
configFilePath,
156+
extendedConfig,
157+
existsSync,
158+
readFileSync
154159
);
155160
}
156161

157-
const base =
158-
loadTsconfig(extendedConfigPath, existsSync, readFileSync) || {};
162+
return mergeTsconfigs(base, config);
163+
}
164+
return config;
165+
}
159166

160-
// baseUrl should be interpreted as relative to the base tsconfig,
161-
// but we need to update it so it is relative to the original tsconfig being loaded
162-
if (base.compilerOptions && base.compilerOptions.baseUrl) {
163-
const extendsDir = path.dirname(extendedConfig);
164-
base.compilerOptions.baseUrl = path.join(
165-
extendsDir,
166-
base.compilerOptions.baseUrl
167-
);
168-
}
167+
/**
168+
* Intended to be called only from loadTsconfig.
169+
* Parameters don't have defaults because they should use the same as loadTsconfig.
170+
*/
171+
function loadTsconfigFromExtends(
172+
configFilePath: string,
173+
extendedConfigValue: string,
174+
// eslint-disable-next-line no-shadow
175+
existsSync: (path: string) => boolean,
176+
readFileSync: (filename: string) => string
177+
): Tsconfig {
178+
if (
179+
typeof extendedConfigValue === "string" &&
180+
extendedConfigValue.indexOf(".json") === -1
181+
) {
182+
extendedConfigValue += ".json";
183+
}
184+
const currentDir = path.dirname(configFilePath);
185+
let extendedConfigPath = path.join(currentDir, extendedConfigValue);
186+
if (
187+
extendedConfigValue.indexOf("/") !== -1 &&
188+
extendedConfigValue.indexOf(".") !== -1 &&
189+
!existsSync(extendedConfigPath)
190+
) {
191+
extendedConfigPath = path.join(
192+
currentDir,
193+
"node_modules",
194+
extendedConfigValue
195+
);
196+
}
169197

170-
return {
171-
...base,
172-
...config,
173-
compilerOptions: {
174-
...base.compilerOptions,
175-
...config.compilerOptions,
176-
},
177-
};
198+
const config =
199+
loadTsconfig(extendedConfigPath, existsSync, readFileSync) || {};
200+
201+
// baseUrl should be interpreted as relative to extendedConfigPath,
202+
// but we need to update it so it is relative to the original tsconfig being loaded
203+
if (config.compilerOptions?.baseUrl) {
204+
const extendsDir = path.dirname(extendedConfigValue);
205+
config.compilerOptions.baseUrl = path.join(
206+
extendsDir,
207+
config.compilerOptions.baseUrl
208+
);
178209
}
210+
179211
return config;
180212
}
213+
214+
function mergeTsconfigs(
215+
base: Tsconfig | undefined,
216+
config: Tsconfig | undefined
217+
): Tsconfig {
218+
base = base || {};
219+
config = config || {};
220+
221+
return {
222+
...base,
223+
...config,
224+
compilerOptions: {
225+
...base.compilerOptions,
226+
...config.compilerOptions,
227+
},
228+
};
229+
}

0 commit comments

Comments
 (0)