Skip to content

Commit 92d8f3e

Browse files
committed
feat(tsconfig): support JSONC comments in tsconfig.json files
1 parent 5cb58ca commit 92d8f3e

File tree

10 files changed

+314
-37
lines changed

10 files changed

+314
-37
lines changed

.cspell.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@
3939
"Natsu",
4040
"tsconfigs",
4141
"preact",
42-
"compat"
42+
"compat",
43+
"Sindre",
44+
"Sorhus"
4345
],
4446
"ignorePaths": [
4547
"package.json",

README.md

Lines changed: 31 additions & 31 deletions
Large diffs are not rendered by default.

lib/TsconfigPathsPlugin.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -530,7 +530,9 @@ module.exports = class TsconfigPathsPlugin {
530530
* @returns {Promise<Tsconfig>} the merged tsconfig
531531
*/
532532
async _loadTsconfig(fileSystem, configFilePath, fileDependencies) {
533-
const config = await readJson(fileSystem, configFilePath);
533+
const config = await readJson(fileSystem, configFilePath, {
534+
stripComments: true,
535+
});
534536
fileDependencies.add(configFilePath);
535537

536538
let result = config;

lib/util/fs.js

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,22 @@
55

66
"use strict";
77

8+
const stripJsonComments = require("./strip-json-comments");
9+
810
/** @typedef {import("../Resolver").FileSystem} FileSystem */
911

1012
/**
11-
* Read and parse JSON file
13+
* Read and parse JSON file (supports JSONC with comments)
1214
* @template T
1315
* @param {FileSystem} fileSystem the file system
1416
* @param {string} jsonFilePath absolute path to JSON file
17+
* @param { {stripComments?: boolean} } options Options
1518
* @returns {Promise<T>} parsed JSON content
1619
*/
17-
async function readJson(fileSystem, jsonFilePath) {
20+
async function readJson(fileSystem, jsonFilePath, options = {}) {
21+
const { stripComments = false } = options;
1822
const { readJson } = fileSystem;
19-
if (readJson) {
23+
if (readJson && !stripComments) {
2024
return new Promise((resolve, reject) => {
2125
readJson(jsonFilePath, (err, content) => {
2226
if (err) return reject(err);
@@ -32,7 +36,12 @@ async function readJson(fileSystem, jsonFilePath) {
3236
});
3337
});
3438

35-
return JSON.parse(/** @type {string} */ (buf.toString()));
39+
const jsonText = /** @type {string} */ (buf.toString());
40+
// Strip comments to support JSONC (e.g., tsconfig.json with comments)
41+
const jsonWithoutComments = stripComments
42+
? stripJsonComments(jsonText)
43+
: jsonText;
44+
return JSON.parse(jsonWithoutComments);
3645
}
3746

3847
module.exports.readJson = readJson;

lib/util/strip-json-comments.js

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
/*
2+
MIT License http://www.opensource.org/licenses/mit-license.php
3+
Author Natsu @xiaoxiaojx
4+
5+
This file contains code ported from strip-json-comments:
6+
https://github.com/sindresorhus/strip-json-comments
7+
Original license: MIT
8+
Original author: Sindre Sorhus
9+
*/
10+
11+
"use strict";
12+
13+
const singleComment = Symbol("singleComment");
14+
const multiComment = Symbol("multiComment");
15+
16+
/**
17+
* Strip without whitespace (returns empty string)
18+
* @param {string} _string Unused
19+
* @param {number} _start Unused
20+
* @param {number} _end Unused
21+
* @returns {string} Empty string for all input
22+
*/
23+
const stripWithoutWhitespace = (_string, _start, _end) => "";
24+
25+
/**
26+
* Replace all characters except ASCII spaces, tabs and line endings with regular spaces to ensure valid JSON output.
27+
* @param {string} string String to process
28+
* @param {number} start Start index
29+
* @param {number} end End index
30+
* @returns {string} Processed string with comments replaced by whitespace
31+
*/
32+
const stripWithWhitespace = (string, start, end) =>
33+
string.slice(start, end).replace(/[^ \t\r\n]/g, " ");
34+
35+
/**
36+
* Check if a quote is escaped
37+
* @param {string} jsonString JSON string
38+
* @param {number} quotePosition Position of the quote
39+
* @returns {boolean} True if the quote at the given position is escaped
40+
*/
41+
const isEscaped = (jsonString, quotePosition) => {
42+
let index = quotePosition - 1;
43+
let backslashCount = 0;
44+
45+
while (jsonString[index] === "\\") {
46+
index -= 1;
47+
backslashCount += 1;
48+
}
49+
50+
return Boolean(backslashCount % 2);
51+
};
52+
53+
/**
54+
* Strip comments from JSON string
55+
* @param {string} jsonString JSON string with potential comments
56+
* @param { {whitespace?: boolean, trailingCommas?: boolean} } options Options
57+
* @returns {string} JSON string without comments
58+
*/
59+
function stripJsonComments(
60+
jsonString,
61+
{ whitespace = true, trailingCommas = false } = {},
62+
) {
63+
if (typeof jsonString !== "string") {
64+
throw new TypeError(
65+
`Expected argument \`jsonString\` to be a \`string\`, got \`${typeof jsonString}\``,
66+
);
67+
}
68+
69+
const strip = whitespace ? stripWithWhitespace : stripWithoutWhitespace;
70+
71+
let isInsideString = false;
72+
/** @type {false | typeof singleComment | typeof multiComment} */
73+
let isInsideComment = false;
74+
let offset = 0;
75+
let buffer = "";
76+
let result = "";
77+
let commaIndex = -1;
78+
79+
for (let index = 0; index < jsonString.length; index++) {
80+
const currentCharacter = jsonString[index];
81+
const nextCharacter = jsonString[index + 1];
82+
83+
if (!isInsideComment && currentCharacter === '"') {
84+
// Enter or exit string
85+
const escaped = isEscaped(jsonString, index);
86+
if (!escaped) {
87+
isInsideString = !isInsideString;
88+
}
89+
}
90+
91+
if (isInsideString) {
92+
continue;
93+
}
94+
95+
if (!isInsideComment && currentCharacter + nextCharacter === "//") {
96+
// Enter single-line comment
97+
buffer += jsonString.slice(offset, index);
98+
offset = index;
99+
isInsideComment = singleComment;
100+
index++;
101+
} else if (
102+
isInsideComment === singleComment &&
103+
currentCharacter + nextCharacter === "\r\n"
104+
) {
105+
// Exit single-line comment via \r\n
106+
index++;
107+
isInsideComment = false;
108+
buffer += strip(jsonString, offset, index);
109+
offset = index;
110+
continue;
111+
} else if (isInsideComment === singleComment && currentCharacter === "\n") {
112+
// Exit single-line comment via \n
113+
isInsideComment = false;
114+
buffer += strip(jsonString, offset, index);
115+
offset = index;
116+
} else if (!isInsideComment && currentCharacter + nextCharacter === "/*") {
117+
// Enter multiline comment
118+
buffer += jsonString.slice(offset, index);
119+
offset = index;
120+
isInsideComment = multiComment;
121+
index++;
122+
continue;
123+
} else if (
124+
isInsideComment === multiComment &&
125+
currentCharacter + nextCharacter === "*/"
126+
) {
127+
// Exit multiline comment
128+
index++;
129+
isInsideComment = false;
130+
buffer += strip(jsonString, offset, index + 1);
131+
offset = index + 1;
132+
continue;
133+
} else if (trailingCommas && !isInsideComment) {
134+
if (commaIndex !== -1) {
135+
if (currentCharacter === "}" || currentCharacter === "]") {
136+
// Strip trailing comma
137+
buffer += jsonString.slice(offset, index);
138+
result += strip(buffer, 0, 1) + buffer.slice(1);
139+
buffer = "";
140+
offset = index;
141+
commaIndex = -1;
142+
} else if (
143+
currentCharacter !== " " &&
144+
currentCharacter !== "\t" &&
145+
currentCharacter !== "\r" &&
146+
currentCharacter !== "\n"
147+
) {
148+
// Hit non-whitespace following a comma; comma is not trailing
149+
buffer += jsonString.slice(offset, index);
150+
offset = index;
151+
commaIndex = -1;
152+
}
153+
} else if (currentCharacter === ",") {
154+
// Flush buffer prior to this point, and save new comma index
155+
result += buffer + jsonString.slice(offset, index);
156+
buffer = "";
157+
offset = index;
158+
commaIndex = index;
159+
}
160+
}
161+
}
162+
163+
const remaining =
164+
isInsideComment === singleComment
165+
? strip(jsonString, offset, jsonString.length)
166+
: jsonString.slice(offset);
167+
168+
return result + buffer + remaining;
169+
}
170+
171+
module.exports = stripJsonComments;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const Button = 'button';
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const bar = 'bar';
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const foo = 'foo';
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
// This is a line comment
3+
"compilerOptions": {
4+
/* This is a block comment */
5+
"baseUrl": ".",
6+
"paths": {
7+
// Another line comment
8+
"@components/*": ["${configDir}/src/components/*"],
9+
"foo": ["${configDir}/src/mapped/foo"],
10+
/* Block comment in paths */
11+
"bar/*": ["${configDir}/src/mapped/bar/*"]
12+
}
13+
}
14+
}
15+

test/tsconfig-paths.test.js

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -979,4 +979,79 @@ describe("TsconfigPathsPlugin", () => {
979979
});
980980
});
981981
});
982+
983+
describe("JSONC support (comments in tsconfig.json)", () => {
984+
const jsoncExampleDir = path.resolve(
985+
__dirname,
986+
"fixtures",
987+
"tsconfig-paths",
988+
"jsonc-comments",
989+
);
990+
991+
it("should parse tsconfig.json with line comments (//)", (done) => {
992+
const resolver = ResolverFactory.createResolver({
993+
fileSystem,
994+
extensions: [".ts", ".tsx"],
995+
mainFields: ["browser", "main"],
996+
mainFiles: ["index"],
997+
tsconfig: path.join(jsoncExampleDir, "tsconfig.json"),
998+
useSyncFileSystemCalls: true,
999+
});
1000+
1001+
resolver.resolve(
1002+
{},
1003+
jsoncExampleDir,
1004+
"@components/button",
1005+
{},
1006+
(err, result) => {
1007+
if (err) return done(err);
1008+
if (!result) return done(new Error("No result"));
1009+
expect(result).toEqual(
1010+
path.join(jsoncExampleDir, "src", "components", "button.ts"),
1011+
);
1012+
done();
1013+
},
1014+
);
1015+
});
1016+
1017+
it("should parse tsconfig.json with block comments (/* */)", (done) => {
1018+
const resolver = ResolverFactory.createResolver({
1019+
fileSystem,
1020+
extensions: [".ts", ".tsx"],
1021+
mainFields: ["browser", "main"],
1022+
mainFiles: ["index"],
1023+
tsconfig: path.join(jsoncExampleDir, "tsconfig.json"),
1024+
useSyncFileSystemCalls: true,
1025+
});
1026+
1027+
resolver.resolve({}, jsoncExampleDir, "bar/index", {}, (err, result) => {
1028+
if (err) return done(err);
1029+
if (!result) return done(new Error("No result"));
1030+
expect(result).toEqual(
1031+
path.join(jsoncExampleDir, "src", "mapped", "bar", "index.ts"),
1032+
);
1033+
done();
1034+
});
1035+
});
1036+
1037+
it("should parse tsconfig.json with mixed comments", (done) => {
1038+
const resolver = ResolverFactory.createResolver({
1039+
fileSystem,
1040+
extensions: [".ts", ".tsx"],
1041+
mainFields: ["browser", "main"],
1042+
mainFiles: ["index"],
1043+
tsconfig: path.join(jsoncExampleDir, "tsconfig.json"),
1044+
useSyncFileSystemCalls: true,
1045+
});
1046+
1047+
resolver.resolve({}, jsoncExampleDir, "foo", {}, (err, result) => {
1048+
if (err) return done(err);
1049+
if (!result) return done(new Error("No result"));
1050+
expect(result).toEqual(
1051+
path.join(jsoncExampleDir, "src", "mapped", "foo", "index.ts"),
1052+
);
1053+
done();
1054+
});
1055+
});
1056+
});
9821057
});

0 commit comments

Comments
 (0)