diff --git a/biome.jsonc b/biome.jsonc index 30993ebf..91e93aad 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -1,7 +1,7 @@ { "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", "files": { - "includes": ["**", "!**/*.snap.cjs", "!**/fixtures/**", "!**/expected/**", "!**/input/**"] + "includes": ["**", "!**/*.snap.cjs", "!**/fixtures/**", "!**/tests/**"] }, "assist": { "actions": { "source": { "organizeImports": "off" } } }, // Rules for the linter diff --git a/package-lock.json b/package-lock.json index c45410cf..a9c9b8cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1347,9 +1347,9 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.12", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", - "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -1366,15 +1366,15 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", - "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.29", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", - "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", + "version": "0.3.30", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", + "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -1424,6 +1424,10 @@ "resolved": "recipes/import-assertions-to-attributes", "link": true }, + "node_modules/@nodejs/node-url-to-whatwg-url": { + "resolved": "recipes/node-url-to-whatwg-url", + "link": true + }, "node_modules/@nodejs/process-main-module": { "resolved": "recipes/process-main-module", "link": true @@ -1656,13 +1660,13 @@ } }, "node_modules/@types/node-fetch": { - "version": "2.6.12", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.12.tgz", - "integrity": "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==", + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", "license": "MIT", "dependencies": { "@types/node": "*", - "form-data": "^4.0.0" + "form-data": "^4.0.4" } }, "node_modules/abort-controller": { @@ -1825,9 +1829,9 @@ } }, "node_modules/browserslist": { - "version": "4.25.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", - "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", + "version": "4.25.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.2.tgz", + "integrity": "sha512-0si2SJK3ooGzIawRu61ZdPCO1IncZwS8IzuX73sPZsXW6EQ/w/DAfPyKI8l1ETTCr2MnvqWitmlCUxgdul45jA==", "funding": [ { "type": "opencollective", @@ -1844,8 +1848,8 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001726", - "electron-to-chromium": "^1.5.173", + "caniuse-lite": "^1.0.30001733", + "electron-to-chromium": "^1.5.199", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, @@ -1900,9 +1904,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001727", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", - "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==", + "version": "1.0.30001734", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001734.tgz", + "integrity": "sha512-uhE1Ye5vgqju6OI71HTQqcBCZrvHugk0MjLak7Q+HfoBgoq5Bi+5YnwjP4fjDgrtYr/l8MVRBvzz9dPD4KyK0A==", "funding": [ { "type": "opencollective", @@ -2218,9 +2222,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.191", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.191.tgz", - "integrity": "sha512-xcwe9ELcuxYLUFqZZxL19Z6HVKcvNkIwhbHUz7L3us6u12yR+7uY89dSl570f/IqNthx8dAw3tojG7i4Ni4tDA==", + "version": "1.5.200", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.200.tgz", + "integrity": "sha512-rFCxROw7aOe4uPTfIAx+rXv9cEcGx+buAF4npnhtTqCJk5KDFRnh3+KYj7rdVh6lsFt5/aPs+Irj9rZ33WMA7w==", "license": "ISC" }, "node_modules/emoji-regex": { @@ -2397,9 +2401,9 @@ } }, "node_modules/flow-parser": { - "version": "0.277.1", - "resolved": "https://registry.npmjs.org/flow-parser/-/flow-parser-0.277.1.tgz", - "integrity": "sha512-86F5PGl+OrFvCzyK04id9Yf9rxFB8485GPs5sexB4cVLOXmpHbSi1/dYiaemI53I85CpImBu/qHVmZnGQflgmw==", + "version": "0.278.0", + "resolved": "https://registry.npmjs.org/flow-parser/-/flow-parser-0.278.0.tgz", + "integrity": "sha512-9oUcYDHf9n+E/t0FXndgBqGbaUsGEcmWqIr1ldqCzTzctsJV5E/bHusOj4ThB72Ss2mqWpLFNz0+o2c1O8J6+A==", "license": "MIT", "engines": { "node": ">=0.4.0" @@ -3231,9 +3235,9 @@ } }, "node_modules/openai/node_modules/@types/node": { - "version": "18.19.120", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.120.tgz", - "integrity": "sha512-WtCGHFXnVI8WHLxDAt5TbnCM4eSE+nI0QN2NJtwzcgMhht2eNz6V9evJrk+lwC8bCY8OWV5Ym8Jz7ZEyGnKnMA==", + "version": "18.19.122", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.122.tgz", + "integrity": "sha512-yzegtT82dwTNEe/9y+CM8cgb42WrUfMMCg2QqSddzO1J6uPmBD7qKCZ7dOHZP2Yrpm/kb0eqdNMn2MUyEiqBmA==", "license": "MIT", "dependencies": { "undici-types": "~5.26.4" @@ -4026,9 +4030,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", - "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", + "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", "license": "ISC", "bin": { "yaml": "bin.mjs" @@ -4084,6 +4088,14 @@ "@codemod.com/jssg-types": "^1.0.3" } }, + "recipes/node-url-to-whatwg-url": { + "name": "@nodejs/node-url-to-whatwg-url", + "version": "1.0.0", + "license": "MIT", + "devDependencies": { + "@codemod.com/jssg-types": "^1.0.3" + } + }, "recipes/process-main-module": { "name": "@nodejs/process-main-module", "version": "1.0.1", diff --git a/recipes/node-url-to-whatwg-url/README.md b/recipes/node-url-to-whatwg-url/README.md new file mode 100644 index 00000000..71b79318 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/README.md @@ -0,0 +1,84 @@ +# Node.js URL to WHATWG URL + +This recipe converts Node.js `url` module usage to the WHATWG URL API. It modifies code that uses the legacy `url` module to use the modern `URL` class instead. + +See [DEP0116](https://nodejs.org/api/deprecations.html#DEP0116). + +## Example + +### `url.parse` to `new URL()` + +**Before:** +```js +const url = require('node:url'); + +const myURL = url.parse('https://example.com/path?query=string#hash'); + +const { auth } = myURL; +const urlAuth = myURL.auth; + +const { path } = myURL; +const urlPath = myURL.path; + +const { hostname } = myURL; +const urlHostname = myURL.hostname; +``` + +**After:** +```js +const myURL = new URL('https://example.com/path?query=string#hash'); + +const auth = `${myURL.username}:${myURL.password}`; +const urlAuth = `${myURL.username}:${myURL.password}`; + +const path = `${myURL.pathname}${myURL.search}`; +const urlPath = `${myURL.pathname}${myURL.search}`; + +const hostname = myURL.hostname.replace(/^\[|\]$/, ''); +const urlHostname = myURL.hostname.replace(/^\[|\]$/, ''); +``` + +### `url.format` to `myUrl.toString() + +**Before:** +```js +const url = require('node:url'); + +url.format({ + protocol: 'https', + hostname: 'example.com', + pathname: '/some/path', + query: { + page: 1, + format: 'json', + }, +}); +``` + +**After:** +```js +const myUrl = new URL('https://example.com/some/path?page=1&format=json').toString(); +``` + +## Caveats + +The [`url.resolve`](https://nodejs.org/api/url.html#urlresolvefrom-to) method is not directly translatable to the WHATWG URL API. You may need to implement custom logic to handle URL resolution in your application. + +```js +function resolve(from, to) { + const resolvedUrl = new URL(to, new URL(from, 'resolve://')); + if (resolvedUrl.protocol === 'resolve:') { + // `from` is a relative URL. + const { pathname, search, hash } = resolvedUrl; + return pathname + search + hash; + } + return resolvedUrl.toString(); +} + +resolve('/one/two/three', 'four'); // '/one/two/four' +resolve('http://example.com/', '/one'); // 'http://example.com/one' +resolve('http://example.com/one', '/two'); // 'http://example.com/two' +``` + +If you are using `url.parse().auth`, `url.parse().username`, or `url.parse().password`. Will transform to `new URL().auth` which is not valid WHATWG url property. So you have to manually construct the `auth`, `path`, and `hostname` properties as shown in the examples above. + diff --git a/recipes/node-url-to-whatwg-url/codemod.yaml b/recipes/node-url-to-whatwg-url/codemod.yaml new file mode 100644 index 00000000..15a429b8 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/codemod.yaml @@ -0,0 +1,21 @@ +schema_version: "1.0" +name: "@nodejs/node-url-to-whatwg-url" +version: 1.0.0 +description: Handle DEP0116 via transforming `url.parse` to `new URL()` +author: Augustin Mauroy +license: MIT +workflow: workflow.yaml +category: migration + +targets: + languages: + - javascript + - typescript + +keywords: + - transformation + - migration + +registry: + access: public + visibility: public diff --git a/recipes/node-url-to-whatwg-url/package.json b/recipes/node-url-to-whatwg-url/package.json new file mode 100644 index 00000000..9c149108 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/package.json @@ -0,0 +1,24 @@ +{ + "name": "@nodejs/node-url-to-whatwg-url", + "version": "1.0.0", + "description": "Handle DEP0116 via transforming `url.parse` to `new URL()`", + "type": "module", + "scripts": { + "test": "node --run test:import-process && node --run test:url-format && node --run test:url-parse", + "test:import-process": "npx codemod jssg test -l typescript ./src/import-process.ts ./tests/ --filter import-process", + "test:url-format": "npx codemod jssg test -l typescript ./src/url-format.ts ./tests/ --filter url-format", + "test:url-parse": "npx codemod jssg test -l typescript ./src/url-parse.ts ./tests/ --filter url-parse" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/nodejs/userland-migrations.git", + "directory": "recipes/node-url-to-whatwg-url", + "bugs": "https://github.com/nodejs/userland-migrations/issues" + }, + "author": "Augustin Mauroy", + "license": "MIT", + "homepage": "https://github.com/nodejs/userland-migrations/tree/main/node-url-to-whatwg-url#readme", + "devDependencies": { + "@codemod.com/jssg-types": "^1.0.3" + } +} diff --git a/recipes/node-url-to-whatwg-url/src/import-process.ts b/recipes/node-url-to-whatwg-url/src/import-process.ts new file mode 100644 index 00000000..8326acc0 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/src/import-process.ts @@ -0,0 +1,111 @@ +import type { SgRoot, SgNode, Edit, Range } from "@codemod.com/jssg-types/main"; +import { getNodeImportStatements } from "@nodejs/codemod-utils/ast-grep/import-statement"; +import { getNodeRequireCalls } from "@nodejs/codemod-utils/ast-grep/require-call"; +import { removeLines } from "@nodejs/codemod-utils/ast-grep/remove-lines"; + +const isBindingUsed = (rootNode: SgNode, name: string): boolean => { + const refs = rootNode.findAll({ rule: { pattern: name } }); + // Heuristic: declaration counts as one; any other usage yields > 1 + return refs.length > 1; + }; + +/** + * Clean up unused imports/requires from 'node:url' after transforms using shared utils + */ +export default function transform(root: SgRoot): string | null { + const rootNode = root.root(); + const edits: Edit[] = []; + + const linesToRemove: Range[] = []; + + // 1) ES Module imports: import ... from 'node:url' + const esmImports = getNodeImportStatements(root, "url"); + + for (const imp of esmImports) { + const clause = imp.find({ rule: { kind: "import_clause" } }); + let removed = false; + if (clause) { + const nsId = clause.find({ rule: { kind: "namespace_import" } })?.find({ rule: { kind: "identifier" } }); + if (nsId && !isBindingUsed(rootNode, nsId.text())) { + linesToRemove.push(imp.range()); + removed = true; + } + if (removed) continue; + + const specs = clause.findAll({ rule: { kind: "import_specifier" } }); + + if (specs.length === 0 && !nsId) { + const defaultId = clause.find({ rule: { kind: "identifier" } }); + if (defaultId && !isBindingUsed(rootNode, defaultId.text())) { + linesToRemove.push(imp.range()); + removed = true; + } + if (removed) continue; + } + + if (specs.length > 0) { + const keepTexts: string[] = []; + for (const spec of specs) { + const text = spec.text().trim(); + const bindingName = text.includes(" as ") ? text.split(/\s+as\s+/)[1] : text; + if (bindingName && isBindingUsed(rootNode, bindingName)) keepTexts.push(text); + } + if (keepTexts.length === 0) { + linesToRemove.push(imp.range()); + } else if (keepTexts.length !== specs.length) { + const namedImportsNode = clause.find({ rule: { kind: "named_imports" } }); + if (namedImportsNode) edits.push(namedImportsNode.replace(`{ ${keepTexts.join(", ")} }`)); + } + } + } + } + + // 2) CommonJS requires: const ... = require('node:url') + const requireDecls = getNodeRequireCalls(root, "url"); + + for (const decl of requireDecls) { + const id = decl.find({ rule: { kind: "identifier" } }); + const hasObjectPattern = decl.find({ rule: { kind: "object_pattern" } }); + + if (id && !hasObjectPattern) { + if (!isBindingUsed(rootNode, id.text())) linesToRemove.push(decl.parent().range()); + continue; + } + + if (hasObjectPattern) { + const names: string[] = []; + const shorts = decl.findAll({ + rule: { kind: "shorthand_property_identifier_pattern" } + }); + for (const s of shorts) names.push(s.text()); + const pairs = decl.findAll({ rule: { kind: "pair_pattern" } }); + + for (const pair of pairs) { + const aliasId = pair.find({ rule: { kind: "identifier" } }); + if (aliasId) names.push(aliasId.text()); + } + + const usedTexts: string[] = []; + for (const s of shorts) { + if (isBindingUsed(rootNode, s.text())) usedTexts.push(s.text()); + } + for (const pair of pairs) { + const aliasId = pair.find({ rule: { kind: "identifier" } }); + if (aliasId && isBindingUsed(rootNode, aliasId.text())) usedTexts.push(pair.text()); + } + + if (usedTexts.length === 0) { + linesToRemove.push(decl.parent().range()); + } else if (usedTexts.length !== names.length) { + const objPat = decl.find({ rule: { kind: "object_pattern" } }); + if (objPat) edits.push(objPat.replace(`{ ${usedTexts.join(", ")} }`)); + } + } + } + + if (edits.length === 0 && linesToRemove.length === 0) return null; + + const source = rootNode.commitEdits(edits); + + return removeLines(source, linesToRemove); +}; diff --git a/recipes/node-url-to-whatwg-url/src/url-format.ts b/recipes/node-url-to-whatwg-url/src/url-format.ts new file mode 100644 index 00000000..c1fdc6cb --- /dev/null +++ b/recipes/node-url-to-whatwg-url/src/url-format.ts @@ -0,0 +1,288 @@ +import type { SgRoot, Edit, SgNode } from "@codemod.com/jssg-types/main"; +import { getNodeImportStatements } from "@nodejs/codemod-utils/ast-grep/import-statement"; +import { getNodeRequireCalls } from "@nodejs/codemod-utils/ast-grep/require-call"; +import { resolveBindingPath } from "@nodejs/codemod-utils/ast-grep/resolve-binding-path"; + +type V = { literal: true; text: string } | { literal: false; code: string }; + +type UrlState = { + protocol?: V; + auth?: V; // user:pass + host?: V; // host:port + hostname?: V; + port?: V; + pathname?: V; + search?: V; // ?a=b + hash?: V; // #frag + queryParams?: Array<[string, string]>; +}; + +const handledProps: (keyof UrlState)[] = [ + "protocol", + "auth", + "host", + "hostname", + "port", + "pathname", + "search", + "hash", +]; + +const isHandledProp = (key: string): key is (typeof handledProps)[number] => { + return handledProps.includes(key as (typeof handledProps)[number]); +}; + +/** + * Get the literal text value of a node, if it exists. + * @param node The node to extract the literal text from + * @returns The literal text value, or undefined if not found + */ +const getLiteralText = (node: SgNode | null | undefined): string | undefined => { + if (!node) return undefined; + const kind = node.kind(); + + if (kind === "string") { + const frag = node.find({ rule: { kind: "string_fragment" } }); + return frag ? frag.text() : node.text().slice(1, -1); + } + if (kind === "number") return node.text(); + if (kind === "true" || kind === "false") return node.text(); + return undefined; +}; + +/** + * Get the value of a pair node. + * @param pair The pair node to extract the value from + * @returns The value of the pair node, or undefined if not found + */ +const getValue = (pair: SgNode): V | undefined => { + // string/number/bool + const litNode = pair.find({ + rule: { any: [{ kind: "string" }, { kind: "number" }, { kind: "true" }, { kind: "false" }] }, + }); + const lit = getLiteralText(litNode); + if (lit !== undefined) return { literal: true, text: lit }; + + // identifier value + const idNode = pair.find({ rule: { kind: "identifier" } }); + if (idNode) return { literal: false, code: idNode.text() }; + + // shorthand property + const shorthand = pair.find({ rule: { kind: "shorthand_property_identifier" } }); + if (shorthand) return { literal: false, code: shorthand.text() }; + + // template string value + const template = pair.find({ rule: { kind: "template_string" } }); + if (template) return { literal: false, code: template.text() }; + + return undefined; +}; + +/** + * Transforms url.format() calls to new URL().toString() + * @param callNode The AST nodes representing the url.format() calls + * @param edits The edits collector + */ +function urlFormatToUrlToString(callNode: SgNode[], edits: Edit[]): void { + for (const call of callNode) { + const optionsMatch = call.getMatch("OPTIONS"); + if (!optionsMatch) continue; + + // Find the object node that contains the URL options + const objectNode = optionsMatch.find({ rule: { kind: "object" } }); + if (!objectNode) continue; + + const urlState: UrlState = {}; + + const pairs = objectNode.findAll({ rule: { kind: "pair" } }); + + for (const pair of pairs) { + const keyNode = pair.find({ rule: { kind: "property_identifier" } }); + const key = keyNode?.text(); + if (!key) continue; + + if (key === "query") { + // Collect query object literals into key=value + const queryObj = pair.find({ rule: { kind: "object" } }); + if (queryObj) { + const qpairs = queryObj.findAll({ rule: { kind: "pair" } }); + const list: Array<[string, string]> = []; + for (const qp of qpairs) { + const qkeyNode = qp.find({ rule: { kind: "property_identifier" } }); + const qvalLiteral = getLiteralText( + qp.find({ + rule: { + any: [ + { kind: "string" }, + { kind: "number" }, + { kind: "true" }, + { kind: "false" }, + ], + }, + }), + ); + if (qkeyNode && qvalLiteral !== undefined) list.push([qkeyNode.text(), qvalLiteral]); + } + urlState.queryParams = list; + } + continue; + } + + // value might be literal or identifier/shorthand/template + const val = getValue(pair); + if (!val) continue; + + if (isHandledProp(key)) { + if (key === "protocol" && val.literal) { + urlState.protocol = { literal: true, text: val.text.replace(/:$/, "") }; + } else { + if (key !== "queryParams") { + urlState[key] = val; + } + } + } + } + + // Also handle shorthand properties like `{ search }` + const shorthands = objectNode.findAll({ rule: { kind: "shorthand_property_identifier" } }); + for (const sh of shorthands) { + const name = sh.text(); + const v: V = { literal: false, code: name }; + if (isHandledProp(name)) { + if (name !== "queryParams") { + urlState[name] = v; + } + } + } + + // Build output segments + type Seg = { type: "lit"; text: string } | { type: "expr"; code: string }; + const segs: Seg[] = []; + const pushVal = (v?: V) => { + if (!v) return; + if (v.literal) { + if (v.text) segs.push({ type: "lit", text: v.text }); + } else { + // v is the non-literal branch here + segs.push({ type: "expr", code: (v as Extract).code }); + } + }; + + // protocol:// + if (urlState.protocol) { + pushVal(urlState.protocol); + segs.push({ type: "lit", text: "://" }); + } + + // auth@ + if (urlState.auth) { + pushVal(urlState.auth); + segs.push({ type: "lit", text: "@" }); + } + + // host or hostname[:port] + if (urlState.host) { + pushVal(urlState.host); + } else { + if (urlState.hostname) pushVal(urlState.hostname); + if (urlState.port) { + if (urlState.hostname) segs.push({ type: "lit", text: ":" }); + pushVal(urlState.port); + } + } + + // pathname + if (urlState.pathname) { + const p = urlState.pathname; + if (p.literal) { + const text = p.text && !p.text.startsWith("/") ? `/${p.text}` : p.text; + if (text) segs.push({ type: "lit", text }); + } else { + pushVal(p); + } + } + + // search or build from query + if (urlState.search) { + const s = urlState.search; + if (s.literal) { + const text = s.text ? (s.text.startsWith("?") ? s.text : `?${s.text}`) : ""; + if (text) segs.push({ type: "lit", text }); + } else { + pushVal(s); + } + } else if (urlState.queryParams && urlState.queryParams.length > 0) { + const qs = urlState.queryParams.map(([k, v]) => `${k}=${v}`).join("&"); + if (qs) segs.push({ type: "lit", text: `?${qs}` }); + } + + // hash + if (urlState.hash) { + const h = urlState.hash; + if (h.literal) { + const text = h.text ? (h.text.startsWith("#") ? h.text : `#${h.text}`) : ""; + if (text) segs.push({ type: "lit", text }); + } else { + pushVal(h); + } + } + + if (!segs.length) continue; + + const hasExpr = segs.some((s) => s.type === "expr"); + let finalExpr: string; + if (hasExpr) { + const esc = (s: string) => + s.replace(/`/g, "\\`").replace(/\\\$/g, "\\\\$").replace(/\$\{/g, "\\${"); + finalExpr = `\`${segs.map((s) => (s.type === "lit" ? esc(s.text) : `\${${s.code}}`)).join("")}\``; + } else { + finalExpr = `'${segs.map((s) => (s.type === "lit" ? s.text : "")).join("")}'`; + } + + // Include semicolon if original statement had one + const hadSemi = /;\s*$/.test(call.text()); + const replacement = `new URL(${finalExpr}).toString()${hadSemi ? ";" : ""}`; + edits.push(call.replace(replacement)); + } +} + +/** + * Transforms `url.format` usage to `new URL().toString()`. + * + * See https://nodejs.org/api/deprecations.html#DEP0116 + * + * Handle: + * 1. `url.format(options)` → `new URL().toString()` + * 2. `format(options)` → `new URL().toString()` + * if imported with aliases + * 2. `foo.format(options)` → `new URL().toString()` + * 3. `foo(options)` → `new URL().toString()` + */ +export default function transform(root: SgRoot): string | null { + const rootNode = root.root(); + const edits: Edit[] = []; + + // Safety: only run on files that import/require node:url + const importNodes = getNodeImportStatements(root, "url"); + const requireNodes = getNodeRequireCalls(root, "url"); + const requiresImports = [...importNodes, ...requireNodes]; + + if (!requiresImports.length) return null; + + const parseCallPatterns = new Set(); + + for (const node of requiresImports) { + const binding = resolveBindingPath(node, "$.format"); + if (binding) parseCallPatterns.add(`${binding}($OPTIONS)`); + } + + for (const pattern of parseCallPatterns) { + const calls = rootNode.findAll({ rule: { pattern } }); + + if (calls.length) urlFormatToUrlToString(calls, edits); + } + + if (!edits.length) return null; + + return rootNode.commitEdits(edits); +} diff --git a/recipes/node-url-to-whatwg-url/src/url-parse.ts b/recipes/node-url-to-whatwg-url/src/url-parse.ts new file mode 100644 index 00000000..3b5b0c14 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/src/url-parse.ts @@ -0,0 +1,226 @@ +import type { SgRoot, Edit } from "@codemod.com/jssg-types/main"; +import { getNodeImportStatements } from "@nodejs/codemod-utils/ast-grep/import-statement"; +import { getNodeRequireCalls } from "@nodejs/codemod-utils/ast-grep/require-call"; +import { resolveBindingPath } from "@nodejs/codemod-utils/ast-grep/resolve-binding-path"; + +/** + * Transforms `url.parse` usage to `new URL()`. + * + * See https://nodejs.org/api/deprecations.html#DEP0116 for more details. + * + * Handle: + * 1. `url.parse(urlString)` → `new URL(urlString)` + * 2. `parse(urlString)` → `new URL(urlString)` + * if imported with aliases + * 2. `foo.parse(urlString)` → `new URL(urlString)` + * 3. `foo(urlString)` → `new URL(urlString)` + */ +export default function transform(root: SgRoot): string | null { + const rootNode = root.root(); + const edits: Edit[] = []; + + // Safety: only run on files that import/require node:url + const hasNodeUrlImport = + getNodeImportStatements(root, "url").length > 0 || + getNodeRequireCalls(root, "url").length > 0; + + if (!hasNodeUrlImport) return null; + + // 1) Replace parse calls with new URL() using binding-aware patterns + const importNodes = getNodeImportStatements(root, "url"); + const requireNodes = getNodeRequireCalls(root, "url"); + const parseCallPatterns = new Set(); + + for (const node of [...importNodes, ...requireNodes]) { + const binding = resolveBindingPath(node, "$.parse"); + if (!binding) continue; + parseCallPatterns.add(`${binding}($ARG)`); + } + + // 1.a) Identify variables assigned from parse(...) so we only rewrite legacy + // properties (auth, path, hostname) on those specific objects + const parseResultVars = new Set(); + for (const pattern of parseCallPatterns) { + const matches = rootNode.findAll({ + rule: { + any: [ + { pattern: `const $OBJ = ${pattern}` }, + { pattern: `let $OBJ = ${pattern}` }, + { pattern: `var $OBJ = ${pattern}` }, + { pattern: `$OBJ = ${pattern}` } + ] + } + }); + for (const m of matches) { + const obj = m.getMatch("OBJ"); + if (!obj) continue; + const name = obj.text(); + if (/^[$A-Z_a-z][$\w]*$/.test(name)) parseResultVars.add(name); + } + } + + // 1.b) Replace parse calls with new URL() + // Also, for declarations using `var`, upgrade to `let` while keeping `const` as-is. + for (const pattern of parseCallPatterns) { + const calls = rootNode.findAll({ rule: { pattern } }); + + for (const call of calls) { + const arg = call.getMatch("ARG"); + if (!arg) continue; + + const replacement = `new URL(${arg.text()})`; + edits.push(call.replace(replacement)); + } + + // Upgrade `var` → `let` when a declaration's initializer matches parse(...) + const varDecls = rootNode.findAll({ rule: { pattern: `var $OBJ = ${pattern}` } }); + for (const decl of varDecls) { + const text = decl.text(); + const updated = text.replace(/^var\b/, "let"); + if (updated !== text) edits.push(decl.replace(updated)); + } + } + + // 2) Transform legacy properties on URL object + // - auth => `${obj.username}:${obj.password}` + // - path => `${obj.pathname}${obj.search}` + // - hostname => obj.hostname.replace(/^[\[|\]]$/, '') (strip square brackets) + const fieldsToReplace = [ + { + key: "auth", + replaceFn: (base: string, hadSemi: boolean, declKind: "const" | "let" | "var") => { + const kind = declKind === "var" ? "let" : declKind; + return `${kind} auth = \`\${${base}.username}:\${${base}.password}\`${hadSemi ? ";" : ""}`; + }, + }, + { + key: "path", + replaceFn: (base: string, hadSemi: boolean, declKind: "const" | "let" | "var") => { + const kind = declKind === "var" ? "let" : declKind; + return `${kind} path = \`\${${base}.pathname}\${${base}.search}\`${hadSemi ? ";" : ""}`; + }, + }, + { + key: "hostname", + replaceFn: (base: string, hadSemi: boolean, declKind: "const" | "let" | "var") => { + const kind = declKind === "var" ? "let" : declKind; + return `${kind} hostname = ${base}.hostname.replace(/^\\[|\\]$/, '')${hadSemi ? ";" : ""}`; + }, + }, + ]; + + for (const { key, replaceFn } of fieldsToReplace) { + // 2.a) Handle property access for identifiers that originate from parse(...) + for (const varName of parseResultVars) { + const propertyAccesses = rootNode.findAll({ rule: { pattern: `${varName}.${key}` } }); + for (const node of propertyAccesses) { + let replacement = ""; + if (key === "auth") { + replacement = `\`\${${varName}.username}:\${${varName}.password}\``; + } else if (key === "path") { + replacement = `\`\${${varName}.pathname}\${${varName}.search}\``; + } else if (key === "hostname") { + replacement = `${varName}.hostname.replace(/^\\[|\\]$/, '')`; + } + edits.push(node.replace(replacement)); + } + + // destructuring for identifiers without looping kinds + const destructures = rootNode.findAll({ + rule: { + any: [ + { pattern: `const { ${key} } = ${varName}` }, + { pattern: `let { ${key} } = ${varName}` }, + { pattern: `var { ${key} } = ${varName}` } + ] + } + }); + for (const node of destructures) { + const text = node.text(); + const hadSemi = /;\s*$/.test(text); + const declKind: "const" | "let" | "var" = text.trimStart().startsWith("var ") ? "var" : (text.trimStart().startsWith("const ") ? "const" : "let"); + const replacement = replaceFn(varName, hadSemi, declKind); + edits.push(node.replace(replacement)); + } + } + + // 2.b) Handle direct call expressions like parse(...).auth and + // destructuring from parse(...) + for (const pattern of parseCallPatterns) { + const directAccesses = rootNode.findAll({ rule: { pattern: `${pattern}.${key}` } }); + for (const node of directAccesses) { + // Reconstruct base as the matched expression before .key + const baseExpr = node.text().replace(new RegExp(`\\.${key}$`), ""); + let replacement = ""; + if (key === "auth") { + replacement = `\`\${${baseExpr}.username}:\${${baseExpr}.password}\``; + } else if (key === "path") { + replacement = `\`\${${baseExpr}.pathname}\${${baseExpr}.search}\``; + } else if (key === "hostname") { + replacement = `${baseExpr}.hostname.replace(/^\\[|\\]$/, '')`; + } + edits.push(node.replace(replacement)); + } + + + + // direct destructuring from parse(...), cover all kinds in a single query + const directDestructures = rootNode.findAll({ + rule: { + any: [ + { pattern: `const { ${key} } = ${pattern}` }, + { pattern: `let { ${key} } = ${pattern}` }, + { pattern: `var { ${key} } = ${pattern}` } + ] + } + }); + for (const node of directDestructures) { + const text = node.text(); + const hadSemi = /;\s*$/.test(text); + const rhsText = text.replace(/^[^{]+{\s*[^}]+\s*}\s*=\s*/, ""); + const declKind: "const" | "let" | "var" = text.trimStart().startsWith("var ") ? "var" : (text.trimStart().startsWith("const ") ? "const" : "let"); + const replacement = replaceFn(rhsText, hadSemi, declKind); + edits.push(node.replace(replacement)); + } + } + + // 2.c) Handle property access and destructuring after parse calls were + // replaced with new URL($ARG) + const newURLAccesses = rootNode.findAll({ rule: { pattern: `new URL($ARG).${key}` } }); + for (const node of newURLAccesses) { + const baseExpr = node.text().replace(new RegExp(`\\.${key}$`), ""); + let replacement = ""; + if (key === "auth") { + replacement = `\`\${${baseExpr}.username}:\${${baseExpr}.password}\``; + } else if (key === "path") { + replacement = `\`\${${baseExpr}.pathname}\${${baseExpr}.search}\``; + } else if (key === "hostname") { + replacement = `${baseExpr}.hostname.replace(/^\\[|\\]$/, '')`; + } + edits.push(node.replace(replacement)); + } + + // destructuring from new URL, single query for all kinds + const newURLDestructures = rootNode.findAll({ + rule: { + any: [ + { pattern: `const { ${key} } = new URL($ARG)` }, + { pattern: `let { ${key} } = new URL($ARG)` }, + { pattern: `var { ${key} } = new URL($ARG)` } + ] + } + }); + for (const node of newURLDestructures) { + const text = node.text(); + const hadSemi = /;\s*$/.test(text); + const rhsText = text.replace(/^[^{]+{\s*[^}]+\s*}\s*=\s*/, ""); + const declKind: "const" | "let" | "var" = text.trimStart().startsWith("var ") ? "var" : (text.trimStart().startsWith("const ") ? "const" : "let"); + const replacement = replaceFn(rhsText, hadSemi, declKind); + edits.push(node.replace(replacement)); + } + } + + if (!edits.length) return null; + + return rootNode.commitEdits(edits); +}; diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-1.js b/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-1.js new file mode 100644 index 00000000..95854893 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-1.js @@ -0,0 +1,2 @@ + +const x = 1; diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-2.js b/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-2.js new file mode 100644 index 00000000..1b094505 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-2.js @@ -0,0 +1,3 @@ +import { format as fmt } from 'node:url'; + +console.log(fmt); diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-3.js b/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-3.js new file mode 100644 index 00000000..a5c04cfc --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-3.js @@ -0,0 +1,2 @@ + +function foo() { return 1; } diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-4.js b/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-4.js new file mode 100644 index 00000000..8563cef9 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-4.js @@ -0,0 +1,3 @@ +const { format: fmt } = require('node:url'); + +fmt('x'); diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-5.js b/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-5.js new file mode 100644 index 00000000..d2770664 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-5.js @@ -0,0 +1,2 @@ + +export const v = 42; diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-6.js b/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-6.js new file mode 100644 index 00000000..999741ed --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-6.js @@ -0,0 +1,2 @@ + +doStuff(); diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-7.js b/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-7.js new file mode 100644 index 00000000..033b9802 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/import-process/expected/file-7.js @@ -0,0 +1,3 @@ +import util from "node:util"; + +const x = 1; diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/input/file-1.js b/recipes/node-url-to-whatwg-url/tests/import-process/input/file-1.js new file mode 100644 index 00000000..97a850a2 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/import-process/input/file-1.js @@ -0,0 +1,3 @@ +import url from 'node:url'; + +const x = 1; diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/input/file-2.js b/recipes/node-url-to-whatwg-url/tests/import-process/input/file-2.js new file mode 100644 index 00000000..b3025e60 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/import-process/input/file-2.js @@ -0,0 +1,3 @@ +import { parse, format as fmt } from 'node:url'; + +console.log(fmt); diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/input/file-3.js b/recipes/node-url-to-whatwg-url/tests/import-process/input/file-3.js new file mode 100644 index 00000000..4dc78c3c --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/import-process/input/file-3.js @@ -0,0 +1,3 @@ +const url = require('node:url'); + +function foo() { return 1; } diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/input/file-4.js b/recipes/node-url-to-whatwg-url/tests/import-process/input/file-4.js new file mode 100644 index 00000000..25e5c4ac --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/import-process/input/file-4.js @@ -0,0 +1,3 @@ +const { parse, format: fmt } = require('node:url'); + +fmt('x'); diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/input/file-5.js b/recipes/node-url-to-whatwg-url/tests/import-process/input/file-5.js new file mode 100644 index 00000000..71d51251 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/import-process/input/file-5.js @@ -0,0 +1,3 @@ +import * as url from 'node:url'; + +export const v = 42; diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/input/file-6.js b/recipes/node-url-to-whatwg-url/tests/import-process/input/file-6.js new file mode 100644 index 00000000..e6769006 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/import-process/input/file-6.js @@ -0,0 +1,3 @@ +const { parse } = require('node:url'); + +doStuff(); diff --git a/recipes/node-url-to-whatwg-url/tests/import-process/input/file-7.js b/recipes/node-url-to-whatwg-url/tests/import-process/input/file-7.js new file mode 100644 index 00000000..04be6851 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/import-process/input/file-7.js @@ -0,0 +1,4 @@ +import url from "node:url"; +import util from "node:util"; + +const x = 1; diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-1.js b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-1.js new file mode 100644 index 00000000..b6f28419 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-1.js @@ -0,0 +1,9 @@ +const url = require('node:url'); + +const str = new URL('https://example.com/some/path?page=1').toString(); + +const foo = 'https'; + +const search = '?page=1'; + +const str2 = new URL(`${foo}://example.com/some/path${search}`).toString(); diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-10.js b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-10.js new file mode 100644 index 00000000..5f942c25 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-10.js @@ -0,0 +1,3 @@ +const { format } = require('node:url') + +const a = new URL('https://example.com:8080/p?page=1&format=json#frag').toString() diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-2.js b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-2.js new file mode 100644 index 00000000..5be5dd20 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-2.js @@ -0,0 +1,3 @@ +const { format } = require('node:url'); + +const a = new URL('https://example.com:8080/p?page=1&format=json#frag').toString(); diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-3.js b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-3.js new file mode 100644 index 00000000..87dd3d98 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-3.js @@ -0,0 +1,3 @@ +const nodeUrl = require('node:url'); + +const b = new URL('http://example.com:3000/x').toString(); diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-4.mjs b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-4.mjs new file mode 100644 index 00000000..56dc93a6 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-4.mjs @@ -0,0 +1,3 @@ +import url from 'node:url'; + +const a = new URL('https://example.com/esm-default').toString(); diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-5.mjs b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-5.mjs new file mode 100644 index 00000000..8fafd63c --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-5.mjs @@ -0,0 +1,3 @@ +import { format as fmt } from 'node:url'; + +const b = new URL('http://example.org:8080/path?q=1').toString(); diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-6.js b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-6.js new file mode 100644 index 00000000..54c9109f --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-6.js @@ -0,0 +1,3 @@ +const { format } = require('node:url'); + +const c = new URL('https://user:pass@example.com/has-auth').toString(); diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-7.js b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-7.js new file mode 100644 index 00000000..65210f4e --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-7.js @@ -0,0 +1,6 @@ +const { format } = require('node:url'); + +const search = 'page=2'; +const proto = 'https:'; + +const d = new URL(`${proto}://example.com:443/norm${search}`).toString(); diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-8.js b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-8.js new file mode 100644 index 00000000..8e28a1fc --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-8.js @@ -0,0 +1,3 @@ +const { format } = require('node:url'); + +const e = new URL('https://example.com:3000/no-leading-slash').toString(); diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-9.js b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-9.js new file mode 100644 index 00000000..18a33bea --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/expected/file-9.js @@ -0,0 +1,3 @@ +const url = require('node:url') + +const str = new URL('https://example.com/some/path?page=1').toString() diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/input/file-1.js b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-1.js new file mode 100644 index 00000000..c33c1224 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-1.js @@ -0,0 +1,19 @@ +const url = require('node:url'); + +const str = url.format({ + protocol: 'https', + hostname: 'example.com', + pathname: '/some/path', + search: '?page=1' +}); + +const foo = 'https'; + +const search = '?page=1'; + +const str2 = url.format({ + protocol: foo, + hostname: 'example.com', + pathname: '/some/path', + search +}); diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/input/file-10.js b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-10.js new file mode 100644 index 00000000..f69be481 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-10.js @@ -0,0 +1,9 @@ +const { format } = require('node:url') + +const a = format({ + protocol: 'https:', + host: 'example.com:8080', + pathname: 'p', + query: { page: '1', format: 'json' }, + hash: 'frag', +}) diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/input/file-2.js b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-2.js new file mode 100644 index 00000000..4f33ebd9 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-2.js @@ -0,0 +1,9 @@ +const { format } = require('node:url'); + +const a = format({ + protocol: 'https:', + host: 'example.com:8080', + pathname: 'p', + query: { page: '1', format: 'json' }, + hash: 'frag', +}); diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/input/file-3.js b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-3.js new file mode 100644 index 00000000..ef9bffca --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-3.js @@ -0,0 +1,8 @@ +const nodeUrl = require('node:url'); + +const b = nodeUrl.format({ + protocol: 'http', + hostname: 'example.com', + port: '3000', + pathname: '/x', +}); diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/input/file-4.mjs b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-4.mjs new file mode 100644 index 00000000..d6a5b131 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-4.mjs @@ -0,0 +1,7 @@ +import url from 'node:url'; + +const a = url.format({ + protocol: 'https', + hostname: 'example.com', + pathname: '/esm-default', +}); diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/input/file-5.mjs b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-5.mjs new file mode 100644 index 00000000..c6905fbf --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-5.mjs @@ -0,0 +1,8 @@ +import { format as fmt } from 'node:url'; + +const b = fmt({ + protocol: 'http', + host: 'example.org:8080', + pathname: 'path', + search: 'q=1', +}); diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/input/file-6.js b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-6.js new file mode 100644 index 00000000..1477a48a --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-6.js @@ -0,0 +1,8 @@ +const { format } = require('node:url'); + +const c = format({ + protocol: 'https', + auth: 'user:pass', + hostname: 'example.com', + pathname: '/has-auth', +}); diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/input/file-7.js b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-7.js new file mode 100644 index 00000000..862906a9 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-7.js @@ -0,0 +1,11 @@ +const { format } = require('node:url'); + +const search = 'page=2'; +const proto = 'https:'; + +const d = format({ + protocol: proto, + host: 'example.com:443', + pathname: '/norm', + search, +}); diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/input/file-8.js b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-8.js new file mode 100644 index 00000000..7c6ff694 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-8.js @@ -0,0 +1,8 @@ +const { format } = require('node:url'); + +const e = format({ + protocol: 'https', + hostname: 'example.com', + port: '3000', + pathname: 'no-leading-slash', +}); diff --git a/recipes/node-url-to-whatwg-url/tests/url-format/input/file-9.js b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-9.js new file mode 100644 index 00000000..83897892 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-format/input/file-9.js @@ -0,0 +1,8 @@ +const url = require('node:url') + +const str = url.format({ + protocol: 'https', + hostname: 'example.com', + pathname: '/some/path', + search: '?page=1' +}) diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-1.js b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-1.js new file mode 100644 index 00000000..c02bc68c --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-1.js @@ -0,0 +1,18 @@ +const url = require('node:url'); + +const myURL = new URL('https://example.com/path?query=string#hash'); + +const auth = `${myURL.username}:${myURL.password}`; +const urlAuth = `${myURL.username}:${myURL.password}`; + +const path = `${myURL.pathname}${myURL.search}`; +const urlPath = `${myURL.pathname}${myURL.search}`; + +const hostname = myURL.hostname.replace(/^\[|\]$/, ''); +const urlHostname = myURL.hostname.replace(/^\[|\]$/, ''); + +const someObject = { + auth: 'life is a waterfall' +} + +console.log(someObject.auth); diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-2.js b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-2.js new file mode 100644 index 00000000..b621e7f2 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-2.js @@ -0,0 +1,12 @@ +const nodeUrl = require('node:url'); + +const myURL = new URL('https://example.com/path?query=string#hash'); + +const auth = `${myURL.username}:${myURL.password}`; +const urlAuth = `${myURL.username}:${myURL.password}`; + +const path = `${myURL.pathname}${myURL.search}`; +const urlPath = `${myURL.pathname}${myURL.search}`; + +const hostname = myURL.hostname.replace(/^\[|\]$/, ''); +const urlHostname = myURL.hostname.replace(/^\[|\]$/, ''); diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-3.js b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-3.js new file mode 100644 index 00000000..063540c3 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-3.js @@ -0,0 +1,12 @@ +const { parse } = require('node:url'); + +const myURL = new URL('https://example.com/path?query=string#hash'); + +const auth = `${myURL.username}:${myURL.password}`; +const urlAuth = `${myURL.username}:${myURL.password}`; + +const path = `${myURL.pathname}${myURL.search}`; +const urlPath = `${myURL.pathname}${myURL.search}`; + +const hostname = myURL.hostname.replace(/^\[|\]$/, ''); +const urlHostname = myURL.hostname.replace(/^\[|\]$/, ''); diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-4.js b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-4.js new file mode 100644 index 00000000..d50c076e --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-4.js @@ -0,0 +1,12 @@ +const { parse: urlParse } = require('node:url'); + +const myURL = new URL('https://example.com/path?query=string#hash'); + +const auth = `${myURL.username}:${myURL.password}`; +const urlAuth = `${myURL.username}:${myURL.password}`; + +const path = `${myURL.pathname}${myURL.search}`; +const urlPath = `${myURL.pathname}${myURL.search}`; + +const hostname = myURL.hostname.replace(/^\[|\]$/, ''); +const urlHostname = myURL.hostname.replace(/^\[|\]$/, ''); diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-5.mjs b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-5.mjs new file mode 100644 index 00000000..ce1957a2 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-5.mjs @@ -0,0 +1,12 @@ +import url from 'node:url'; + +const myURL = new URL('https://example.com/path?query=string#hash'); + +const auth = `${myURL.username}:${myURL.password}`; +const urlAuth = `${myURL.username}:${myURL.password}`; + +const path = `${myURL.pathname}${myURL.search}`; +const urlPath = `${myURL.pathname}${myURL.search}`; + +const hostname = myURL.hostname.replace(/^\[|\]$/, ''); +const urlHostname = myURL.hostname.replace(/^\[|\]$/, ''); diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-6.mjs b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-6.mjs new file mode 100644 index 00000000..83a2cdbd --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-6.mjs @@ -0,0 +1,12 @@ +import nodeUrl from 'node:url'; + +const myURL = new URL('https://example.com/path?query=string#hash'); + +const auth = `${myURL.username}:${myURL.password}`; +const urlAuth = `${myURL.username}:${myURL.password}`; + +const path = `${myURL.pathname}${myURL.search}`; +const urlPath = `${myURL.pathname}${myURL.search}`; + +const hostname = myURL.hostname.replace(/^\[|\]$/, ''); +const urlHostname = myURL.hostname.replace(/^\[|\]$/, ''); diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-7.mjs b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-7.mjs new file mode 100644 index 00000000..89ae4f85 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-7.mjs @@ -0,0 +1,12 @@ +import { parse } from 'node:url'; + +const myURL = new URL('https://example.com/path?query=string#hash'); + +const auth = `${myURL.username}:${myURL.password}`; +const urlAuth = `${myURL.username}:${myURL.password}`; + +const path = `${myURL.pathname}${myURL.search}`; +const urlPath = `${myURL.pathname}${myURL.search}`; + +const hostname = myURL.hostname.replace(/^\[|\]$/, ''); +const urlHostname = myURL.hostname.replace(/^\[|\]$/, ''); diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-8.mjs b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-8.mjs new file mode 100644 index 00000000..c8300629 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-8.mjs @@ -0,0 +1,12 @@ +import { parse as urlParse } from 'node:url'; + +const myURL = new URL('https://example.com/path?query=string#hash'); + +const auth = `${myURL.username}:${myURL.password}`; +const urlAuth = `${myURL.username}:${myURL.password}`; + +const path = `${myURL.pathname}${myURL.search}`; +const urlPath = `${myURL.pathname}${myURL.search}`; + +const hostname = myURL.hostname.replace(/^\[|\]$/, ''); +const urlHostname = myURL.hostname.replace(/^\[|\]$/, ''); diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-9.js b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-9.js new file mode 100644 index 00000000..c861e91b --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/expected/file-9.js @@ -0,0 +1,12 @@ +const url = require('node:url') + +const myURL = new URL('https://example.com/path?query=string#hash') + +const auth = `${myURL.username}:${myURL.password}` +const urlAuth = `${myURL.username}:${myURL.password}` + +const path = `${myURL.pathname}${myURL.search}` +const urlPath = `${myURL.pathname}${myURL.search}` + +const hostname = myURL.hostname.replace(/^\[|\]$/, '') +const urlHostname = myURL.hostname.replace(/^\[|\]$/, '') diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-1.js b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-1.js new file mode 100644 index 00000000..c5097d0f --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-1.js @@ -0,0 +1,18 @@ +const url = require('node:url'); + +const myURL = url.parse('https://example.com/path?query=string#hash'); + +const { auth } = myURL; +const urlAuth = myURL.auth; + +const { path } = myURL; +const urlPath = myURL.path; + +const { hostname } = myURL; +const urlHostname = myURL.hostname; + +const someObject = { + auth: 'life is a waterfall' +} + +console.log(someObject.auth); diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-2.js b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-2.js new file mode 100644 index 00000000..c18e819d --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-2.js @@ -0,0 +1,12 @@ +const nodeUrl = require('node:url'); + +const myURL = nodeUrl.parse('https://example.com/path?query=string#hash'); + +const { auth } = myURL; +const urlAuth = myURL.auth; + +const { path } = myURL; +const urlPath = myURL.path; + +const { hostname } = myURL; +const urlHostname = myURL.hostname; diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-3.js b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-3.js new file mode 100644 index 00000000..6a2540f9 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-3.js @@ -0,0 +1,12 @@ +const { parse } = require('node:url'); + +const myURL = parse('https://example.com/path?query=string#hash'); + +const { auth } = myURL; +const urlAuth = myURL.auth; + +const { path } = myURL; +const urlPath = myURL.path; + +const { hostname } = myURL; +const urlHostname = myURL.hostname; diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-4.js b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-4.js new file mode 100644 index 00000000..83024dcb --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-4.js @@ -0,0 +1,12 @@ +const { parse: urlParse } = require('node:url'); + +const myURL = urlParse('https://example.com/path?query=string#hash'); + +const { auth } = myURL; +const urlAuth = myURL.auth; + +const { path } = myURL; +const urlPath = myURL.path; + +const { hostname } = myURL; +const urlHostname = myURL.hostname; diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-5.mjs b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-5.mjs new file mode 100644 index 00000000..db58b025 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-5.mjs @@ -0,0 +1,12 @@ +import url from 'node:url'; + +const myURL = url.parse('https://example.com/path?query=string#hash'); + +const { auth } = myURL; +const urlAuth = myURL.auth; + +const { path } = myURL; +const urlPath = myURL.path; + +const { hostname } = myURL; +const urlHostname = myURL.hostname; diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-6.mjs b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-6.mjs new file mode 100644 index 00000000..07d9f974 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-6.mjs @@ -0,0 +1,12 @@ +import nodeUrl from 'node:url'; + +const myURL = nodeUrl.parse('https://example.com/path?query=string#hash'); + +const { auth } = myURL; +const urlAuth = myURL.auth; + +const { path } = myURL; +const urlPath = myURL.path; + +const { hostname } = myURL; +const urlHostname = myURL.hostname; diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-7.mjs b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-7.mjs new file mode 100644 index 00000000..687463f6 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-7.mjs @@ -0,0 +1,12 @@ +import { parse } from 'node:url'; + +const myURL = parse('https://example.com/path?query=string#hash'); + +const { auth } = myURL; +const urlAuth = myURL.auth; + +const { path } = myURL; +const urlPath = myURL.path; + +const { hostname } = myURL; +const urlHostname = myURL.hostname; diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-8.mjs b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-8.mjs new file mode 100644 index 00000000..2461384f --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-8.mjs @@ -0,0 +1,12 @@ +import { parse as urlParse } from 'node:url'; + +const myURL = urlParse('https://example.com/path?query=string#hash'); + +const { auth } = myURL; +const urlAuth = myURL.auth; + +const { path } = myURL; +const urlPath = myURL.path; + +const { hostname } = myURL; +const urlHostname = myURL.hostname; diff --git a/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-9.js b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-9.js new file mode 100644 index 00000000..66f4ac90 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/tests/url-parse/input/file-9.js @@ -0,0 +1,12 @@ +const url = require('node:url') + +const myURL = url.parse('https://example.com/path?query=string#hash') + +const { auth } = myURL +const urlAuth = myURL.auth + +const { path } = myURL +const urlPath = myURL.path + +const { hostname } = myURL +const urlHostname = myURL.hostname diff --git a/recipes/node-url-to-whatwg-url/workflow.yaml b/recipes/node-url-to-whatwg-url/workflow.yaml new file mode 100644 index 00000000..2bc23591 --- /dev/null +++ b/recipes/node-url-to-whatwg-url/workflow.yaml @@ -0,0 +1,58 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod-com/codemod/refs/heads/main/schemas/workflow.json + +version: "1" + +nodes: + - id: apply-transforms + name: Apply AST Transformations + type: automatic + runtime: + type: direct + steps: + - name: Handle DEPDEP0116 via transforming `url.parse` to `new URL()` + js-ast-grep: + js_file: src/url-parse.ts + base_path: . + include: + - "**/*.cjs" + - "**/*.cts" + - "**/*.js" + - "**/*.jsx" + - "**/*.mjs" + - "**/*.mts" + - "**/*.ts" + - "**/*.tsx" + exclude: + - "**/node_modules/**" + language: typescript + - name: Handle DEPDEP0116 via transforming `url.format` to `new URL().toString()` + js-ast-grep: + js_file: src/url-format.ts + base_path: . + include: + - "**/*.cjs" + - "**/*.cts" + - "**/*.js" + - "**/*.jsx" + - "**/*.mjs" + - "**/*.mts" + - "**/*.ts" + - "**/*.tsx" + exclude: + - "**/node_modules/**" + language: typescript + - name: Clean up import statements + require calls + js-ast-grep: + js_file: src/import-process.ts + base_path: . + include: + - "**/*.cjs" + - "**/*.cts" + - "**/*.js" + - "**/*.jsx" + - "**/*.mjs" + - "**/*.mts" + - "**/*.ts" + - "**/*.tsx" + exclude: + - "**/node_modules/**"