Skip to content

Commit 47e1a5c

Browse files
authored
Add support for extends as array of strings to v3 (backport of #245) (#260)
TypeScript 5.0 added support for defining "extends" as an array of strings. This commit adds support for this use case.
1 parent a1d731e commit 47e1a5c

2 files changed

Lines changed: 203 additions & 45 deletions

File tree

src/__tests__/tsconfig-loader.test.ts

Lines changed: 110 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ describe("walkForTsConfig", () => {
133133
});
134134

135135
describe("loadConfig", () => {
136-
it("It should load a config", () => {
136+
it("should load a config", () => {
137137
const config = { compilerOptions: { baseUrl: "hej" } };
138138
const res = loadTsconfig(
139139
"/root/dir1/tsconfig.json",
@@ -144,7 +144,7 @@ describe("loadConfig", () => {
144144
expect(res).toStrictEqual(config);
145145
});
146146

147-
it("It should load a config with comments", () => {
147+
it("should load a config with comments", () => {
148148
const config = { compilerOptions: { baseUrl: "hej" } };
149149
const res = loadTsconfig(
150150
"/root/dir1/tsconfig.json",
@@ -160,7 +160,7 @@ describe("loadConfig", () => {
160160
expect(res).toStrictEqual(config);
161161
});
162162

163-
it("It should load a config with trailing commas", () => {
163+
it("should load a config with trailing commas", () => {
164164
const config = { compilerOptions: { baseUrl: "hej" } };
165165
const res = loadTsconfig(
166166
"/root/dir1/tsconfig.json",
@@ -175,7 +175,21 @@ describe("loadConfig", () => {
175175
expect(res).toStrictEqual(config);
176176
});
177177

178-
it("It should load a config with extends and overwrite all options", () => {
178+
it("should throw an error including the file path when encountering invalid JSON5", () => {
179+
expect(() =>
180+
loadTsconfig(
181+
"/root/dir1/tsconfig.json",
182+
(path) => path === "/root/dir1/tsconfig.json",
183+
(_) => `{
184+
"compilerOptions": {
185+
}`
186+
)
187+
).toThrowError(
188+
"/root/dir1/tsconfig.json is malformed JSON5: invalid end of input at 3:12"
189+
);
190+
});
191+
192+
it("should load a config with string extends and overwrite all options", () => {
179193
const firstConfig = {
180194
extends: "../base-config.json",
181195
compilerOptions: { baseUrl: "kalle", paths: { foo: ["bar2"] } },
@@ -221,7 +235,7 @@ describe("loadConfig", () => {
221235
});
222236
});
223237

224-
it("It should load a config with extends from node_modules and overwrite all options", () => {
238+
it("should load a config with string extends from node_modules and overwrite all options", () => {
225239
const firstConfig = {
226240
extends: "my-package/base-config.json",
227241
compilerOptions: { baseUrl: "kalle", paths: { foo: ["bar2"] } },
@@ -273,7 +287,7 @@ describe("loadConfig", () => {
273287
});
274288
});
275289

276-
it("Should use baseUrl relative to location of extended tsconfig", () => {
290+
it("should use baseUrl relative to location of extended tsconfig", () => {
277291
const firstConfig = { compilerOptions: { baseUrl: "." } };
278292
const firstConfigPath = join("/root", "first-config.json");
279293
const secondConfig = { extends: "../first-config.json" };
@@ -309,4 +323,94 @@ describe("loadConfig", () => {
309323
compilerOptions: { baseUrl: join("..", "..") },
310324
});
311325
});
326+
327+
it("should load a config with array extends and overwrite all options", () => {
328+
const baseConfig1 = {
329+
compilerOptions: { baseUrl: ".", paths: { foo: ["bar"] } },
330+
};
331+
const baseConfig1Path = join("/root", "base-config-1.json");
332+
const baseConfig2 = { compilerOptions: { baseUrl: "." } };
333+
const baseConfig2Path = join("/root", "dir1", "base-config-2.json");
334+
const baseConfig3 = {
335+
compilerOptions: { baseUrl: ".", paths: { foo: ["bar2"] } },
336+
};
337+
const baseConfig3Path = join("/root", "dir1", "dir2", "base-config-3.json");
338+
const actualConfig = {
339+
extends: [
340+
"./base-config-1.json",
341+
"./dir1/base-config-2.json",
342+
"./dir1/dir2/base-config-3.json",
343+
],
344+
};
345+
const actualConfigPath = join("/root", "tsconfig.json");
346+
347+
const res = loadTsconfig(
348+
join("/root", "tsconfig.json"),
349+
(path) =>
350+
[
351+
baseConfig1Path,
352+
baseConfig2Path,
353+
baseConfig3Path,
354+
actualConfigPath,
355+
].indexOf(path) >= 0,
356+
(path) => {
357+
if (path === baseConfig1Path) {
358+
return JSON.stringify(baseConfig1);
359+
}
360+
if (path === baseConfig2Path) {
361+
return JSON.stringify(baseConfig2);
362+
}
363+
if (path === baseConfig3Path) {
364+
return JSON.stringify(baseConfig3);
365+
}
366+
if (path === actualConfigPath) {
367+
return JSON.stringify(actualConfig);
368+
}
369+
return "";
370+
}
371+
);
372+
373+
expect(res).toEqual({
374+
extends: [
375+
"./base-config-1.json",
376+
"./dir1/base-config-2.json",
377+
"./dir1/dir2/base-config-3.json",
378+
],
379+
compilerOptions: {
380+
baseUrl: join("dir1", "dir2"),
381+
paths: { foo: ["bar2"] },
382+
},
383+
});
384+
});
385+
386+
it("should load a config with array extends without .json extension", () => {
387+
const baseConfig = {
388+
compilerOptions: { baseUrl: ".", paths: { foo: ["bar"] } },
389+
};
390+
const baseConfigPath = join("/root", "base-config-1.json");
391+
const actualConfig = { extends: ["./base-config-1"] };
392+
const actualConfigPath = join("/root", "tsconfig.json");
393+
394+
const res = loadTsconfig(
395+
join("/root", "tsconfig.json"),
396+
(path) => [baseConfigPath, actualConfigPath].indexOf(path) >= 0,
397+
(path) => {
398+
if (path === baseConfigPath) {
399+
return JSON.stringify(baseConfig);
400+
}
401+
if (path === actualConfigPath) {
402+
return JSON.stringify(actualConfig);
403+
}
404+
return "";
405+
}
406+
);
407+
408+
expect(res).toEqual({
409+
extends: ["./base-config-1"],
410+
compilerOptions: {
411+
baseUrl: ".",
412+
paths: { foo: ["bar"] },
413+
},
414+
});
415+
});
312416
});

src/tsconfig-loader.ts

Lines changed: 93 additions & 39 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> };
@@ -122,51 +122,105 @@ export function loadTsconfig(
122122

123123
const configString = readFileSync(configFilePath);
124124
const cleanedJson = StripBom(configString);
125-
const config: Tsconfig = JSON5.parse(cleanedJson);
126-
let extendedConfig = config.extends;
125+
let config: Tsconfig;
126+
try {
127+
config = JSON5.parse(cleanedJson);
128+
} catch (e) {
129+
throw new Error(`${configFilePath} is malformed ${e.message}`);
130+
}
127131

132+
let extendedConfig = config.extends;
128133
if (extendedConfig) {
129-
if (
130-
typeof extendedConfig === "string" &&
131-
extendedConfig.indexOf(".json") === -1
132-
) {
133-
extendedConfig += ".json";
134-
}
135-
const currentDir = path.dirname(configFilePath);
136-
let extendedConfigPath = path.join(currentDir, extendedConfig);
137-
if (
138-
extendedConfig.indexOf("/") !== -1 &&
139-
extendedConfig.indexOf(".") !== -1 &&
140-
!existsSync(extendedConfigPath)
141-
) {
142-
extendedConfigPath = path.join(
143-
currentDir,
144-
"node_modules",
145-
extendedConfig
134+
let base: Tsconfig;
135+
136+
if (Array.isArray(extendedConfig)) {
137+
base = extendedConfig.reduce(
138+
(currBase, extendedConfigElement) =>
139+
mergeTsconfigs(
140+
currBase,
141+
loadTsconfigFromExtends(
142+
configFilePath,
143+
extendedConfigElement,
144+
existsSync,
145+
readFileSync
146+
)
147+
),
148+
{}
149+
);
150+
} else {
151+
base = loadTsconfigFromExtends(
152+
configFilePath,
153+
extendedConfig,
154+
existsSync,
155+
readFileSync
146156
);
147157
}
148158

149-
const base =
150-
loadTsconfig(extendedConfigPath, existsSync, readFileSync) || {};
159+
return mergeTsconfigs(base, config);
160+
}
161+
return config;
162+
}
151163

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

162-
return {
163-
...base,
164-
...config,
165-
compilerOptions: {
166-
...base.compilerOptions,
167-
...config.compilerOptions,
168-
},
169-
};
195+
const config =
196+
loadTsconfig(extendedConfigPath, existsSync, readFileSync) || {};
197+
198+
// baseUrl should be interpreted as relative to extendedConfigPath,
199+
// but we need to update it so it is relative to the original tsconfig being loaded
200+
if (config.compilerOptions?.baseUrl) {
201+
const extendsDir = path.dirname(extendedConfigValue);
202+
config.compilerOptions.baseUrl = path.join(
203+
extendsDir,
204+
config.compilerOptions.baseUrl
205+
);
170206
}
207+
171208
return config;
172209
}
210+
211+
function mergeTsconfigs(
212+
base: Tsconfig | undefined,
213+
config: Tsconfig | undefined
214+
): Tsconfig {
215+
base = base || {};
216+
config = config || {};
217+
218+
return {
219+
...base,
220+
...config,
221+
compilerOptions: {
222+
...base.compilerOptions,
223+
...config.compilerOptions,
224+
},
225+
};
226+
}

0 commit comments

Comments
 (0)