diff --git a/package.json b/package.json index 0d9a2f4a6..465051302 100644 --- a/package.json +++ b/package.json @@ -128,6 +128,7 @@ "@eslint/js": "9.38.0", "@stylistic/eslint-plugin": "5.5.0", "@types/estree": "1.0.8", + "@types/jsesc": "3.0.3", "@typescript-eslint/eslint-plugin": "8.46.2", "@typescript-eslint/parser": "8.46.2", "browserslist": "4.27.0", @@ -156,6 +157,7 @@ "jest-snapshot-serializer-ansi": "2.2.1", "jest-snapshot-serializer-raw": "2.0.0", "jest-watch-typeahead": "3.0.1", + "jsesc": "3.1.0", "knip": "5.66.2", "magic-string": "0.30.19", "nano-spawn": "2.0.0", diff --git a/scripts/build/transform/index.js b/scripts/build/transform/index.js index a112169f2..7867e8738 100644 --- a/scripts/build/transform/index.js +++ b/scripts/build/transform/index.js @@ -56,13 +56,16 @@ function transform(original, file) { continue; } - transform.transform(node); + changed ||= true; if (transform.inject) { injected.add(transform.inject); } - changed ||= true; + const replacement = transform.transform(node); + if (replacement && replacement !== node) { + replaceNode(node, replacement); + } } }); @@ -95,4 +98,12 @@ function transform(original, file) { return code; } +function replaceNode(original, object) { + for (const key of Object.keys(original)) { + delete original[key]; + } + + Object.assign(original, object); +} + export default transform; diff --git a/scripts/build/transform/transforms/create-method-call-transform.js b/scripts/build/transform/transforms/create-method-call-transform.js index 2b34fb24f..c99672086 100644 --- a/scripts/build/transform/transforms/create-method-call-transform.js +++ b/scripts/build/transform/transforms/create-method-call-transform.js @@ -1,4 +1,5 @@ import { fileURLToPath } from "node:url"; +import { createIdentifier, isIdentifier } from "./utilities.js"; /** * @param {import("@babel/types").Node} node @@ -15,8 +16,7 @@ function isMethodCall(node, { methodName, argumentsLength }) { node.callee.type === "OptionalMemberExpression") && !node.callee.computed && node.callee.object.type !== "ThisExpression" && - node.callee.property.type === "Identifier" && - node.callee.property.name === methodName + isIdentifier(node.callee.property, methodName) ); } @@ -29,16 +29,22 @@ function isMethodCall(node, { methodName, argumentsLength }) { */ function transformMethodCallToFunctionCall(node, functionName) { // `__at(isOptionalObject, object, ...arguments)` - node.arguments.unshift( - { - type: "BooleanLiteral", - value: node.callee.type === "OptionalMemberExpression", - leadingComments: [{ type: "CommentBlock", value: " isOptionalObject " }], - }, - node.callee.object, - ); - node.callee = { type: "Identifier", name: functionName }; + return { + ...node, + callee: createIdentifier(functionName), + arguments: [ + { + type: "BooleanLiteral", + value: node.callee.type === "OptionalMemberExpression", + leadingComments: [ + { type: "CommentBlock", value: " isOptionalObject " }, + ], + }, + node.callee.object, + ...node.arguments, + ], + }; } function createMethodCallTransform({ diff --git a/scripts/build/transform/transforms/index.js b/scripts/build/transform/transforms/index.js index b8a0b79cb..1a40c1f4f 100644 --- a/scripts/build/transform/transforms/index.js +++ b/scripts/build/transform/transforms/index.js @@ -3,6 +3,7 @@ import transformArrayFindLastIndex from "./transform-array-find-last-index.js"; import transformArrayToReversed from "./transform-array-to-reversed.js"; import transformObjectHasOwnCall from "./transform-object-has-own.js"; import transformRelativeIndexing from "./transform-relative-indexing.js"; +import transformStringRaw from "./transform-string-raw.js"; import transformStringReplaceAll from "./transform-string-replace-all.js"; // These transforms are like Babel and core-js @@ -15,10 +16,12 @@ export default [ transformArrayFindLastIndex, // Node.js 16.9.0 transformObjectHasOwnCall, - // Node.js 16.6.0 + // Node.js 16.6.0, and for performance transformRelativeIndexing, // Node.js 15.0.0 transformStringReplaceAll, // Node.js 20 transformArrayToReversed, + // For performance + transformStringRaw, ]; diff --git a/scripts/build/transform/transforms/transform-object-has-own.js b/scripts/build/transform/transforms/transform-object-has-own.js index 0f4ca2ee9..ef3618158 100644 --- a/scripts/build/transform/transforms/transform-object-has-own.js +++ b/scripts/build/transform/transforms/transform-object-has-own.js @@ -1,3 +1,5 @@ +import { createMemberExpression, isIdentifier } from "./utilities.js"; + /* `Object.hasOwn(foo, "bar")` -> `Object.prototype.hasOwnProperty.call(foo, "bar")` */ @@ -8,40 +10,24 @@ const transformObjectHasOwnCall = { * @param {import("@babel/types").Node} node * @returns {boolean} */ - test(node) { - return ( - node.type === "CallExpression" && - !node.optional && - node.arguments.length === 2 && - node.arguments.every(({ type }) => type !== "SpreadElement") && - node.callee.type === "MemberExpression" && - !node.callee.optional && - !node.callee.computed && - node.callee.object.type === "Identifier" && - node.callee.object.name === "Object" && - node.callee.property.type === "Identifier" && - node.callee.property.name === "hasOwn" - ); - }, + test: (node) => + node.type === "CallExpression" && + !node.optional && + node.arguments.length === 2 && + node.arguments.every(({ type }) => type !== "SpreadElement") && + node.callee.type === "MemberExpression" && + !node.callee.optional && + !node.callee.computed && + isIdentifier(node.callee.object, "Object") && + isIdentifier(node.callee.property, "hasOwn"), /** * @param {import("@babel/types").CallExpression} node * @returns {import("@babel/types").CallExpression} */ - transform(node) { - node.callee = { - type: "MemberExpression", - object: { - type: "MemberExpression", - object: { - type: "MemberExpression", - object: { type: "Identifier", name: "Object" }, - property: { type: "Identifier", name: "prototype" }, - }, - property: { type: "Identifier", name: "hasOwnProperty" }, - }, - property: { type: "Identifier", name: "call" }, - }; - }, + transform: (node) => ({ + ...node, + callee: createMemberExpression("Object.prototype.hasOwnProperty.call"), + }), }; export default transformObjectHasOwnCall; diff --git a/scripts/build/transform/transforms/transform-string-raw.js b/scripts/build/transform/transforms/transform-string-raw.js new file mode 100644 index 000000000..16e5a0198 --- /dev/null +++ b/scripts/build/transform/transforms/transform-string-raw.js @@ -0,0 +1,47 @@ +import jsesc from "jsesc"; +import { isIdentifier } from "./utilities.js"; + +/** + * @param {import("@babel/types").Node} node + * @returns {boolean} + */ +function isStringRaw(node) { + return ( + node.type === "TaggedTemplateExpression" && + node.tag.type === "MemberExpression" && + !node.tag.computed && + !node.tag.optional && + isIdentifier(node.tag.object, "String") && + isIdentifier(node.tag.property, "raw") + ); +} + +/** + * `` String.raw`foo` `` -> `"foo"` + * `` String.raw`foo ${"bar"}` `` -> `` `foo ${"bar"}` `` + * + * @param {import("@babel/types").TaggedTemplateExpression} node + * @returns {import("@babel/types").TaggedTemplateExpression} + */ +function transformStringRaw(taggedTemplateExpression) { + const templateLiteral = taggedTemplateExpression.quasi; + + if (templateLiteral.expressions.length === 0) { + const { raw } = templateLiteral.quasis[0].value; + const value = new Function(`return \`${jsesc(raw)}\``)(); + return { type: "StringLiteral", value }; + } + + const quasis = templateLiteral.quasis.map((quasis) => { + const { raw } = quasis.value; + return { ...quasis, value: { ...quasis, raw: jsesc(raw) } }; + }); + + return { ...templateLiteral, quasis }; +} + +export default { + shouldSkip: (text /* , file*/) => !text.includes("String.raw`"), + test: isStringRaw, + transform: transformStringRaw, +}; diff --git a/scripts/build/transform/transforms/utilities.js b/scripts/build/transform/transforms/utilities.js new file mode 100644 index 000000000..3c58d9571 --- /dev/null +++ b/scripts/build/transform/transforms/utilities.js @@ -0,0 +1,25 @@ +function isIdentifier(node, name) { + return node.type === "Identifier" && node.name === name; +} + +function createIdentifier(name) { + return { type: "Identifier", name }; +} + +function createMemberExpression(path) { + let node; + + for (const name of path.split(".")) { + node = node + ? { + type: "MemberExpression", + object: node, + property: createIdentifier(name), + } + : createIdentifier(name); + } + + return node; +} + +export { createIdentifier, createMemberExpression, isIdentifier }; diff --git a/scripts/bundle-eslint-config.js b/scripts/bundle-eslint-config.js index 802e95b6c..3d59baee6 100644 --- a/scripts/bundle-eslint-config.js +++ b/scripts/bundle-eslint-config.js @@ -20,6 +20,10 @@ const restrictedSyntaxes = [ selector: String.raw`CallExpression[callee.name="require"][arguments.0.value=/^\..*?\.mjs$/]`, message: ".mjs file can't be `require()`d", }, + { + selector: String.raw`TaggedTemplateExpression > MemberExpression.tag[object.name="String"][property.name="raw"]`, + message: "`String.raw` should be transformed.", + }, ]; const browserRestrictedSyntaxes = [ diff --git a/tests/unit/syntax-transform.js b/tests/unit/syntax-transform.js index 968db53e6..0c2ac821c 100644 --- a/tests/unit/syntax-transform.js +++ b/tests/unit/syntax-transform.js @@ -109,3 +109,12 @@ test("Array#toReversed", () => { `"foo.toReversed(extraArgument)"`, ); }); + +test("String.raw", () => { + expect(transform("String.raw`\\\\\\uINVALID`")).toMatchInlineSnapshot( + String.raw`""\\\\\\uINVALID""`, + ); + expect(transform("String.raw`\\uINVALID${'world'}`")).toMatchInlineSnapshot( + `" \`\\\\uINVALID\${'world'}\`"`, + ); +}); diff --git a/yarn.lock b/yarn.lock index 206ecb69e..b47508d73 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2433,6 +2433,13 @@ __metadata: languageName: node linkType: hard +"@types/jsesc@npm:3.0.3": + version: 3.0.3 + resolution: "@types/jsesc@npm:3.0.3" + checksum: 10/8e97013e91fdbffc7ce23f5be7f0b8a1208e9794ccae887f1a9fd606633d8b570424ab2de81ada47ff9a062812191a7aa764a42a293a37b86fd76f13329a0c0f + languageName: node + linkType: hard + "@types/jsesc@npm:^2.5.0": version: 2.5.1 resolution: "@types/jsesc@npm:2.5.1" @@ -6111,7 +6118,7 @@ __metadata: languageName: node linkType: hard -"jsesc@npm:^3.0.2, jsesc@npm:^3.1.0, jsesc@npm:~3.1.0": +"jsesc@npm:3.1.0, jsesc@npm:^3.0.2, jsesc@npm:^3.1.0, jsesc@npm:~3.1.0": version: 3.1.0 resolution: "jsesc@npm:3.1.0" bin: @@ -7256,6 +7263,7 @@ __metadata: "@prettier/parse-srcset": "npm:3.1.0" "@stylistic/eslint-plugin": "npm:5.5.0" "@types/estree": "npm:1.0.8" + "@types/jsesc": "npm:3.0.3" "@typescript-eslint/eslint-plugin": "npm:8.46.2" "@typescript-eslint/parser": "npm:8.46.2" "@typescript-eslint/typescript-estree": "npm:8.46.2" @@ -7318,6 +7326,7 @@ __metadata: jest-snapshot-serializer-ansi: "npm:2.2.1" jest-snapshot-serializer-raw: "npm:2.0.0" jest-watch-typeahead: "npm:3.0.1" + jsesc: "npm:3.1.0" json5: "npm:2.2.3" knip: "npm:5.66.2" leven: "npm:4.1.0"