Skip to content

Commit 03120de

Browse files
authored
fix(tsconfig): support JSONC comments in tsconfig.json files (#476)
1 parent f7f15d9 commit 03120de

File tree

10 files changed

+325
-37
lines changed

10 files changed

+325
-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
@@ -543,7 +543,9 @@ module.exports = class TsconfigPathsPlugin {
543543
return /** @type {Tsconfig} */ ({});
544544
}
545545
visitedConfigPaths.add(configFilePath);
546-
const config = await readJson(fileSystem, configFilePath);
546+
const config = await readJson(fileSystem, configFilePath, {
547+
stripComments: true,
548+
});
547549
fileDependencies.add(configFilePath);
548550

549551
let result = config;

lib/util/fs.js

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,27 @@
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+
* @typedef {object} ReadJsonOptions
14+
* @property {boolean=} stripComments Whether to strip JSONC comments
15+
*/
16+
17+
/**
18+
* Read and parse JSON file (supports JSONC with comments)
1219
* @template T
1320
* @param {FileSystem} fileSystem the file system
1421
* @param {string} jsonFilePath absolute path to JSON file
22+
* @param {ReadJsonOptions} options Options
1523
* @returns {Promise<T>} parsed JSON content
1624
*/
17-
async function readJson(fileSystem, jsonFilePath) {
25+
async function readJson(fileSystem, jsonFilePath, options = {}) {
26+
const { stripComments = false } = options;
1827
const { readJson } = fileSystem;
19-
if (readJson) {
28+
if (readJson && !stripComments) {
2029
return new Promise((resolve, reject) => {
2130
readJson(jsonFilePath, (err, content) => {
2231
if (err) return reject(err);
@@ -32,7 +41,12 @@ async function readJson(fileSystem, jsonFilePath) {
3241
});
3342
});
3443

35-
return JSON.parse(/** @type {string} */ (buf.toString()));
44+
const jsonText = /** @type {string} */ (buf.toString());
45+
// Strip comments to support JSONC (e.g., tsconfig.json with comments)
46+
const jsonWithoutComments = stripComments
47+
? stripJsonComments(jsonText)
48+
: jsonText;
49+
return JSON.parse(jsonWithoutComments);
3650
}
3751

3852
module.exports.readJson = readJson;

lib/util/strip-json-comments.js

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
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+
/**
14+
* @typedef {object} StripJsonCommentsOptions
15+
* @property {boolean=} whitespace Replace comments with whitespace
16+
* @property {boolean=} trailingCommas Strip trailing commas
17+
*/
18+
19+
const singleComment = Symbol("singleComment");
20+
const multiComment = Symbol("multiComment");
21+
22+
/**
23+
* Strip without whitespace (returns empty string)
24+
* @param {string} _string Unused
25+
* @param {number} _start Unused
26+
* @param {number} _end Unused
27+
* @returns {string} Empty string for all input
28+
*/
29+
const stripWithoutWhitespace = (_string, _start, _end) => "";
30+
31+
/**
32+
* Replace all characters except ASCII spaces, tabs and line endings with regular spaces to ensure valid JSON output.
33+
* @param {string} string String to process
34+
* @param {number} start Start index
35+
* @param {number} end End index
36+
* @returns {string} Processed string with comments replaced by whitespace
37+
*/
38+
const stripWithWhitespace = (string, start, end) =>
39+
string.slice(start, end).replace(/[^ \t\r\n]/g, " ");
40+
41+
/**
42+
* Check if a quote is escaped
43+
* @param {string} jsonString JSON string
44+
* @param {number} quotePosition Position of the quote
45+
* @returns {boolean} True if the quote at the given position is escaped
46+
*/
47+
const isEscaped = (jsonString, quotePosition) => {
48+
let index = quotePosition - 1;
49+
let backslashCount = 0;
50+
51+
while (jsonString[index] === "\\") {
52+
index -= 1;
53+
backslashCount += 1;
54+
}
55+
56+
return Boolean(backslashCount % 2);
57+
};
58+
59+
/**
60+
* Strip comments from JSON string
61+
* @param {string} jsonString JSON string with potential comments
62+
* @param {StripJsonCommentsOptions} options Options
63+
* @returns {string} JSON string without comments
64+
*/
65+
function stripJsonComments(
66+
jsonString,
67+
{ whitespace = true, trailingCommas = false } = {},
68+
) {
69+
if (typeof jsonString !== "string") {
70+
throw new TypeError(
71+
`Expected argument \`jsonString\` to be a \`string\`, got \`${typeof jsonString}\``,
72+
);
73+
}
74+
75+
const strip = whitespace ? stripWithWhitespace : stripWithoutWhitespace;
76+
77+
let isInsideString = false;
78+
/** @type {false | typeof singleComment | typeof multiComment} */
79+
let isInsideComment = false;
80+
let offset = 0;
81+
let buffer = "";
82+
let result = "";
83+
let commaIndex = -1;
84+
85+
for (let index = 0; index < jsonString.length; index++) {
86+
const currentCharacter = jsonString[index];
87+
const nextCharacter = jsonString[index + 1];
88+
89+
if (!isInsideComment && currentCharacter === '"') {
90+
// Enter or exit string
91+
const escaped = isEscaped(jsonString, index);
92+
if (!escaped) {
93+
isInsideString = !isInsideString;
94+
}
95+
}
96+
97+
if (isInsideString) {
98+
continue;
99+
}
100+
101+
if (!isInsideComment && currentCharacter + nextCharacter === "//") {
102+
// Enter single-line comment
103+
buffer += jsonString.slice(offset, index);
104+
offset = index;
105+
isInsideComment = singleComment;
106+
index++;
107+
} else if (
108+
isInsideComment === singleComment &&
109+
currentCharacter + nextCharacter === "\r\n"
110+
) {
111+
// Exit single-line comment via \r\n
112+
index++;
113+
isInsideComment = false;
114+
buffer += strip(jsonString, offset, index);
115+
offset = index;
116+
continue;
117+
} else if (isInsideComment === singleComment && currentCharacter === "\n") {
118+
// Exit single-line comment via \n
119+
isInsideComment = false;
120+
buffer += strip(jsonString, offset, index);
121+
offset = index;
122+
} else if (!isInsideComment && currentCharacter + nextCharacter === "/*") {
123+
// Enter multiline comment
124+
buffer += jsonString.slice(offset, index);
125+
offset = index;
126+
isInsideComment = multiComment;
127+
index++;
128+
continue;
129+
} else if (
130+
isInsideComment === multiComment &&
131+
currentCharacter + nextCharacter === "*/"
132+
) {
133+
// Exit multiline comment
134+
index++;
135+
isInsideComment = false;
136+
buffer += strip(jsonString, offset, index + 1);
137+
offset = index + 1;
138+
continue;
139+
} else if (trailingCommas && !isInsideComment) {
140+
if (commaIndex !== -1) {
141+
if (currentCharacter === "}" || currentCharacter === "]") {
142+
// Strip trailing comma
143+
buffer += jsonString.slice(offset, index);
144+
result += strip(buffer, 0, 1) + buffer.slice(1);
145+
buffer = "";
146+
offset = index;
147+
commaIndex = -1;
148+
} else if (
149+
currentCharacter !== " " &&
150+
currentCharacter !== "\t" &&
151+
currentCharacter !== "\r" &&
152+
currentCharacter !== "\n"
153+
) {
154+
// Hit non-whitespace following a comma; comma is not trailing
155+
buffer += jsonString.slice(offset, index);
156+
offset = index;
157+
commaIndex = -1;
158+
}
159+
} else if (currentCharacter === ",") {
160+
// Flush buffer prior to this point, and save new comma index
161+
result += buffer + jsonString.slice(offset, index);
162+
buffer = "";
163+
offset = index;
164+
commaIndex = index;
165+
}
166+
}
167+
}
168+
169+
const remaining =
170+
isInsideComment === singleComment
171+
? strip(jsonString, offset, jsonString.length)
172+
: jsonString.slice(offset);
173+
174+
return result + buffer + remaining;
175+
}
176+
177+
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
@@ -1004,4 +1004,79 @@ describe("TsconfigPathsPlugin", () => {
10041004
});
10051005
});
10061006
});
1007+
1008+
describe("JSONC support (comments in tsconfig.json)", () => {
1009+
const jsoncExampleDir = path.resolve(
1010+
__dirname,
1011+
"fixtures",
1012+
"tsconfig-paths",
1013+
"jsonc-comments",
1014+
);
1015+
1016+
it("should parse tsconfig.json with line comments (//)", (done) => {
1017+
const resolver = ResolverFactory.createResolver({
1018+
fileSystem,
1019+
extensions: [".ts", ".tsx"],
1020+
mainFields: ["browser", "main"],
1021+
mainFiles: ["index"],
1022+
tsconfig: path.join(jsoncExampleDir, "tsconfig.json"),
1023+
useSyncFileSystemCalls: true,
1024+
});
1025+
1026+
resolver.resolve(
1027+
{},
1028+
jsoncExampleDir,
1029+
"@components/button",
1030+
{},
1031+
(err, result) => {
1032+
if (err) return done(err);
1033+
if (!result) return done(new Error("No result"));
1034+
expect(result).toEqual(
1035+
path.join(jsoncExampleDir, "src", "components", "button.ts"),
1036+
);
1037+
done();
1038+
},
1039+
);
1040+
});
1041+
1042+
it("should parse tsconfig.json with block comments (/* */)", (done) => {
1043+
const resolver = ResolverFactory.createResolver({
1044+
fileSystem,
1045+
extensions: [".ts", ".tsx"],
1046+
mainFields: ["browser", "main"],
1047+
mainFiles: ["index"],
1048+
tsconfig: path.join(jsoncExampleDir, "tsconfig.json"),
1049+
useSyncFileSystemCalls: true,
1050+
});
1051+
1052+
resolver.resolve({}, jsoncExampleDir, "bar/index", {}, (err, result) => {
1053+
if (err) return done(err);
1054+
if (!result) return done(new Error("No result"));
1055+
expect(result).toEqual(
1056+
path.join(jsoncExampleDir, "src", "mapped", "bar", "index.ts"),
1057+
);
1058+
done();
1059+
});
1060+
});
1061+
1062+
it("should parse tsconfig.json with mixed comments", (done) => {
1063+
const resolver = ResolverFactory.createResolver({
1064+
fileSystem,
1065+
extensions: [".ts", ".tsx"],
1066+
mainFields: ["browser", "main"],
1067+
mainFiles: ["index"],
1068+
tsconfig: path.join(jsoncExampleDir, "tsconfig.json"),
1069+
useSyncFileSystemCalls: true,
1070+
});
1071+
1072+
resolver.resolve({}, jsoncExampleDir, "foo", {}, (err, result) => {
1073+
if (err) return done(err);
1074+
if (!result) return done(new Error("No result"));
1075+
expect(result).toEqual(
1076+
path.join(jsoncExampleDir, "src", "mapped", "foo", "index.ts"),
1077+
);
1078+
done();
1079+
});
1080+
});
1081+
});
10071082
});

0 commit comments

Comments
 (0)