diff --git a/codemods/v4/.gitignore b/codemods/v4/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/codemods/v4/README.md b/codemods/v4/README.md new file mode 100644 index 0000000..f0acc44 --- /dev/null +++ b/codemods/v4/README.md @@ -0,0 +1,57 @@ +# Nuxt v3 to v4 Migration Codemod + +This codemod migrates your project from Nuxt v3 to v4, handling the breaking changes: + +- Updates `nuxt.hook('builder:watch')` calls to use absolute paths with proper path normalization +- Transforms null checks to undefined for data fetching variables (`useAsyncData`, `useFetch`) +- Converts deprecated boolean `dedupe` values to new string format in `refresh()` calls +- Adds `{ deep: true }` option to data fetching hooks that need deep reactivity +- Modernizes `addTemplate` calls from `src` property to `getContents` function pattern + +## Installation + +```bash +# Install and run the codemod +npx codemod@latest run @nuxt-v3-to-v4 + +# Or run locally +npx codemod@latest run -w workflow.yaml --target /path/to/your/project --allow-dirty +``` + +## Important Notes + +⚠️ **Backup First**: This codemod modifies code! Run it only on Git-tracked files, and commit or stash changes first. + +⚠️ **Path Normalization**: The `absolute-watch-path` transformation assumes standard Nuxt project structure. Custom watch path handling may need manual adjustment. + +⚠️ **Data Fetching Variables**: If you have custom null checking logic beyond simple equality comparisons, you may need to adjust these manually. + +⚠️ **Complete Migration**: This codemod performs all necessary transformations in the correct order to ensure your code properly migrates to Nuxt v4. + +## Testing Import Utils + +Test the `ensureImport` utility function with various scenarios. Navigate to `codemods/v4/` and run: + +```bash +# Replace case-X with: case-1-add-empty-file, case-2-no-action-exists, ... + +# Test basic import scenarios (cases 1-5, 8-9) +codemod jssg run --language typescript --target tests/import-utils/case-X/input.ts tests/import-utils/test-runner.ts + +# Test type to runtime conversion (case 6) +codemod jssg run --language typescript --target tests/import-utils/case-6-mixed-type-to-runtime/input.ts tests/import-utils/case-6-type-to-runtime-test.ts + +# Test runtime to type conversion (case 7) +codemod jssg run --language typescript --target tests/import-utils/case-7-mixed-runtime-to-type/input.ts tests/import-utils/case-7-runtime-to-type-test.ts + +``` + +**Verify results:** Compare output with the `expected.ts` file in each test directory. + +## Resources + +- [Nuxt v4 Migration Guide](https://nuxt.com/docs/getting-started/upgrade#nuxt-4) + +## License + +MIT diff --git a/codemods/v4/absolute-watch-path/.codemodrc.json b/codemods/v4/absolute-watch-path/.codemodrc.json deleted file mode 100644 index ff25f56..0000000 --- a/codemods/v4/absolute-watch-path/.codemodrc.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "$schema": "https://codemod-utils.s3.us-west-1.amazonaws.com/configuration_schema.json", - "version": "1.0.5", - "private": true, - "name": "nuxt/4/absolute-watch-path", - "engine": "jscodeshift", - "applicability": { - "from": [ - ["nuxt", ">=", "3.0.0"], - ["nuxt", "<", "4.0.0"] - ], - "to": [["nuxt", ">=", "4.0.0"]] - }, - "meta": { - "tags": ["nuxt", "4", "migration"], - "git": "https://github.com/codemod/nuxt-codemods/tree/main/codemods/v4/absolute-watch-path" - }, - "include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx", "**/*.vue"] -} diff --git a/codemods/v4/absolute-watch-path/.gitignore b/codemods/v4/absolute-watch-path/.gitignore deleted file mode 100644 index 76add87..0000000 --- a/codemods/v4/absolute-watch-path/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -node_modules -dist \ No newline at end of file diff --git a/codemods/v4/absolute-watch-path/LICENSE b/codemods/v4/absolute-watch-path/LICENSE deleted file mode 100644 index 8695657..0000000 --- a/codemods/v4/absolute-watch-path/LICENSE +++ /dev/null @@ -1,9 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2024 Codemod Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/codemods/v4/absolute-watch-path/README.md b/codemods/v4/absolute-watch-path/README.md deleted file mode 100644 index 12f169c..0000000 --- a/codemods/v4/absolute-watch-path/README.md +++ /dev/null @@ -1,28 +0,0 @@ -This codemod converts paths emitted by Nuxt's builder:watch hook from relative to absolute, enhancing support for external directories and complex patterns. - -🚦 **Impact Level**: Minimal - -## What Changed - -The Nuxt `builder:watch` hook now emits a path that is absolute rather than relative to your project `srcDir`. - -## Reasons for Change - -This change allows support for watching paths that are outside your `srcDir`, and offers better support for layers and other more complex patterns. - -## Before - -```jsx -nuxt.hook("builder:watch", (event, path) => { - someFunction(); -}); -``` - -## After - -```jsx -nuxt.hook("builder:watch", (event, path) => { - path = relative(nuxt.options.srcDir, resolve(nuxt.options.srcDir, path)); - refreshFunction(); -}); -``` diff --git a/codemods/v4/absolute-watch-path/__testfixtures__/fixture1.input.ts b/codemods/v4/absolute-watch-path/__testfixtures__/fixture1.input.ts deleted file mode 100644 index 527931d..0000000 --- a/codemods/v4/absolute-watch-path/__testfixtures__/fixture1.input.ts +++ /dev/null @@ -1,8 +0,0 @@ -// biome-ignore lint/correctness/useHookAtTopLevel: -nuxt.hook("builder:watch", (event, path) => { - someFunction(); -}); - -nuxt.hook("builder:watch", async (event, key) => - console.log("File changed:", path), -); diff --git a/codemods/v4/absolute-watch-path/__testfixtures__/fixture1.output.ts b/codemods/v4/absolute-watch-path/__testfixtures__/fixture1.output.ts deleted file mode 100644 index a6f5e6d..0000000 --- a/codemods/v4/absolute-watch-path/__testfixtures__/fixture1.output.ts +++ /dev/null @@ -1,12 +0,0 @@ -// biome-ignore lint/correctness/useHookAtTopLevel: -import { relative, resolve } from "node:fs"; - -nuxt.hook("builder:watch", (event, path) => { - path = relative(nuxt.options.srcDir, resolve(nuxt.options.srcDir, path)); - someFunction(); -}); - -nuxt.hook("builder:watch", async (event, key) => { - key = relative(nuxt.options.srcDir, resolve(nuxt.options.srcDir, key)); - return console.log("File changed:", path); -}); diff --git a/codemods/v4/absolute-watch-path/package.json b/codemods/v4/absolute-watch-path/package.json deleted file mode 100644 index 113485d..0000000 --- a/codemods/v4/absolute-watch-path/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "@codemod/nuxt-4-absolute-watch-path", - "private": true, - "dependencies": {}, - "devDependencies": { - "typescript": "^5.2.2", - "ts-node": "^10.9.1", - "jscodeshift": "^0.15.1", - "@types/jscodeshift": "^0.11.10", - "vitest": "^1.0.1", - "@vitest/coverage-v8": "^1.0.1" - }, - "scripts": {}, - "files": [ - "./README.md", - "./.codemodrc.json", - "./dist/index.cjs" - ], - "type": "module" -} diff --git a/codemods/v4/absolute-watch-path/src/index.ts b/codemods/v4/absolute-watch-path/src/index.ts deleted file mode 100644 index d3a934a..0000000 --- a/codemods/v4/absolute-watch-path/src/index.ts +++ /dev/null @@ -1,138 +0,0 @@ -import type { - API, - ArrowFunctionExpression, - FileInfo, - Options, -} from "jscodeshift"; - -export default function transform( - file: FileInfo, - api: API, - options?: Options, -): string | undefined { - const j = api.jscodeshift; - const root = j(file.source); - - let isDirty = false; - - // Add the necessary import statements - const importStatement = j.importDeclaration( - [ - j.importSpecifier(j.identifier("relative")), - j.importSpecifier(j.identifier("resolve")), - ], - j.literal("node:fs"), - ); - - const addImportIfNotExists = () => { - const existingImport = root.find(j.ImportDeclaration, { - source: { value: "node:fs" }, - }); - - let hasRelative = false; - let hasResolve = false; - - existingImport.forEach((path) => { - path.node.specifiers!.forEach((specifier) => { - if (specifier.type === "ImportSpecifier") { - if (specifier.imported.name === "relative") { - hasRelative = true; - } - if (specifier.imported.name === "resolve") { - hasResolve = true; - } - } - }); - }); - - if (!hasRelative || !hasResolve) { - if (existingImport.size() > 0) { - existingImport.forEach((path) => { - if (!hasRelative) { - path.node.specifiers!.push( - j.importSpecifier(j.identifier("relative")), - ); - isDirty = true; - } - if (!hasResolve) { - path.node.specifiers!.push( - j.importSpecifier(j.identifier("resolve")), - ); - isDirty = true; - } - }); - } else { - root.get().node.program.body.unshift(importStatement); - isDirty = true; - } - } - }; - - // Find and modify the `nuxt.hook('builder:watch', async (event, path)` function - root - .find(j.CallExpression, { - callee: { - type: "MemberExpression", - object: { name: "nuxt" }, - property: { name: "hook" }, - }, - arguments: [ - { - type: "StringLiteral", - value: "builder:watch", - }, - { - type: "ArrowFunctionExpression", - }, - ], - }) - .forEach((path) => { - const arrowFunction = path.node.arguments[1] as ArrowFunctionExpression; - if (arrowFunction.params.length === 2) { - const pathParam = arrowFunction.params[1]; - if (j.Identifier.check(pathParam)) { - // Add the import statement if necessary - addImportIfNotExists(); - - const relativeResolveStatement = j.expressionStatement( - j.assignmentExpression( - "=", - j.identifier(pathParam.name), - j.callExpression(j.identifier("relative"), [ - j.memberExpression( - j.memberExpression( - j.identifier("nuxt"), - j.identifier("options"), - ), - j.identifier("srcDir"), - ), - j.callExpression(j.identifier("resolve"), [ - j.memberExpression( - j.memberExpression( - j.identifier("nuxt"), - j.identifier("options"), - ), - j.identifier("srcDir"), - ), - j.identifier(pathParam.name), - ]), - ]), - ), - ); - - if (j.BlockStatement.check(arrowFunction.body)) { - arrowFunction.body.body.unshift(relativeResolveStatement); - isDirty = true; - } else { - arrowFunction.body = j.blockStatement([ - relativeResolveStatement, - j.returnStatement(arrowFunction.body), - ]); - isDirty = true; - } - } - } - }); - - return isDirty ? root.toSource(options) : undefined; -} diff --git a/codemods/v4/absolute-watch-path/tsconfig.json b/codemods/v4/absolute-watch-path/tsconfig.json deleted file mode 100644 index 9811f48..0000000 --- a/codemods/v4/absolute-watch-path/tsconfig.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "compilerOptions": { - "outDir": "./dist", - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "module": "NodeNext", - "skipLibCheck": true, - "strict": true, - "target": "ES6", - "allowJs": true, - "noUncheckedIndexedAccess": true - }, - "include": [ - "./src/**/*.ts", - "./src/**/*.js", - "./test/**/*.ts", - "./test/**/*.js" - ], - "exclude": ["node_modules", "./dist/**/*"], - "ts-node": { - "transpileOnly": true - } -} diff --git a/codemods/v4/absolute-watch-path/vitest.config.ts b/codemods/v4/absolute-watch-path/vitest.config.ts deleted file mode 100644 index 772ad4c..0000000 --- a/codemods/v4/absolute-watch-path/vitest.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { configDefaults, defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - include: [...configDefaults.include, "**/test/*.ts"], - }, -}); diff --git a/codemods/v4/codemod.yaml b/codemods/v4/codemod.yaml new file mode 100644 index 0000000..2301d87 --- /dev/null +++ b/codemods/v4/codemod.yaml @@ -0,0 +1,20 @@ +schema_version: "1.0" + +name: "@nuxt-v3-to-v4" +version: "1.0.0" +description: "Complete migration toolkit for Nuxt 3 to Nuxt 4" +author: "Shadi Bitaraf " +license: "MIT" +workflow: "workflow.yaml" +category: "migration" +repository: "https://github.com/codemod/nuxt-codemods" + +targets: + languages: ["typescript"] + +keywords: ["nuxt", "breaking-change", "migration", "nuxt-v4", "nuxt-v3-to-v4"] + +registry: + access: "public" + visibility: "public" + diff --git a/codemods/v4/default-data-error-value/.gitignore b/codemods/v4/default-data-error-value/.gitignore deleted file mode 100644 index 78174f4..0000000 --- a/codemods/v4/default-data-error-value/.gitignore +++ /dev/null @@ -1,33 +0,0 @@ -# Dependencies -node_modules/ -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# Build artifacts -target/ -dist/ -build/ - -# Temporary files -*.tmp -*.temp -.cache/ - -# Environment files -.env -.env.local - -# IDE files -.vscode/ -.idea/ -*.swp -*.swo - -# OS files -.DS_Store -Thumbs.db - -# Package bundles -*.tar.gz -*.tgz \ No newline at end of file diff --git a/codemods/v4/default-data-error-value/README.md b/codemods/v4/default-data-error-value/README.md deleted file mode 100644 index d9814ea..0000000 --- a/codemods/v4/default-data-error-value/README.md +++ /dev/null @@ -1,107 +0,0 @@ -# default-data-error-value - -Transform null checks to undefined for useAsyncData and useFetch data/error variables - -## Installation - -```bash -# Install from registry -npx codemod@latest run @nuxt/default-data-error-value - -# Or run locally -npx codemod@latest run -w workflow.yaml -``` - -## Usage - -This codemod transforms null checks to undefined for data and error variables from useAsyncData and useFetch hooks. - -### Before - -```tsx -const { data: supercooldata1, error } = useAsyncData( - () => client.value.v1.lists.$select(list.value).fetch(), - { - default: () => shallowRef(), - }, -); - -const { data: supercooldata2, error: error3 } = useFetch( - () => client.value.v1.lists.$select(list.value).fetch(), - { - default: () => shallowRef(), - }, -); - -if (supercooldata1.value === null) { - if (supercooldata2.value === "null") { - if (error.value === null) { - //Something - } else if (error3.value === null) { - } - //Something - } - //Something -} - -let x = - supercooldata1.value === null - ? "Hello" - : error.value === dull - ? "Morning" - : error3.value === null - ? "Hello" - : supercooldata2.value === null - ? "Morning" - : unknown.value === null - ? "Hello" - : "Night"; -let z = unknown.value === null ? "Hello" : "Night"; -``` - -### After - -```tsx -const { data: supercooldata1, error } = useAsyncData( - () => client.value.v1.lists.$select(list.value).fetch(), - { - default: () => shallowRef(), - }, -); - -const { data: supercooldata2, error: error3 } = useFetch( - () => client.value.v1.lists.$select(list.value).fetch(), - { - default: () => shallowRef(), - }, -); - -if (supercooldata1.value === undefined) { - if (supercooldata2.value === "null") { - if (error.value === undefined) { - //Something - } else if (error3.value === undefined) { - } - //Something - } - //Something -} - -let x = - supercooldata1.value === undefined - ? "Hello" - : error.value === dull - ? "Morning" - : error3.value === undefined - ? "Hello" - : supercooldata2.value === undefined - ? "Morning" - : unknown.value === null - ? "Hello" - : "Night"; -let z = unknown.value === null ? "Hello" : "Night"; -``` - -## License - -MIT \ No newline at end of file diff --git a/codemods/v4/default-data-error-value/codemod.yaml b/codemods/v4/default-data-error-value/codemod.yaml deleted file mode 100644 index 64c72d3..0000000 --- a/codemods/v4/default-data-error-value/codemod.yaml +++ /dev/null @@ -1,18 +0,0 @@ -schema_version: "1.0" - -name: "@nuxt/default-data-error-value" -version: "0.1.0" -description: "Transform null checks to undefined for useAsyncData and useFetch data/error variables" -author: Codemod -license: "MIT" -workflow: "workflow.yaml" -category: "migration" - -targets: - languages: ["tsx"] - -keywords: ["nuxt", "v4"] - -registry: - access: "public" - visibility: "public" diff --git a/codemods/v4/default-data-error-value/package.json b/codemods/v4/default-data-error-value/package.json deleted file mode 100644 index f6beee5..0000000 --- a/codemods/v4/default-data-error-value/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "default-data-error-value", - "version": "0.1.0", - "description": "Transform null checks to undefined for useAsyncData and useFetch data/error variables", - "type": "module", - "devDependencies": { - "@codemod.com/jssg-types": "^1.0.7", - "typescript": "^5.8.3" - }, - "scripts": { - "test": "npx codemod@latest jssg test -l typescript ./scripts/codemod.ts", - "check-types": "npx tsc --noEmit" - } -} diff --git a/codemods/v4/default-data-error-value/tests/fixtures/expected.ts b/codemods/v4/default-data-error-value/tests/fixtures/expected.ts deleted file mode 100644 index 7231b4f..0000000 --- a/codemods/v4/default-data-error-value/tests/fixtures/expected.ts +++ /dev/null @@ -1,38 +0,0 @@ -const { data: supercooldata1, error } = useAsyncData( - () => client.value.v1.lists.$select(list.value).fetch(), - { - default: () => shallowRef(), - }, -); - -const { data: supercooldata2, error: error3 } = useFetch( - () => client.value.v1.lists.$select(list.value).fetch(), - { - default: () => shallowRef(), - }, -); - -if (supercooldata1.value === undefined) { - if (supercooldata2.value === "null") { - if (error.value === undefined) { - //Something - } else if (error3.value === undefined) { - } - //Something - } - //Something -} - -let x = - supercooldata1.value === undefined - ? "Hello" - : error.value === dull - ? "Morning" - : error3.value === undefined - ? "Hello" - : supercooldata2.value === undefined - ? "Morning" - : unknown.value === null - ? "Hello" - : "Night"; -let z = unknown.value === null ? "Hello" : "Night"; diff --git a/codemods/v4/default-data-error-value/workflow.yaml b/codemods/v4/default-data-error-value/workflow.yaml deleted file mode 100644 index 1538be7..0000000 --- a/codemods/v4/default-data-error-value/workflow.yaml +++ /dev/null @@ -1,11 +0,0 @@ -version: "1" - -nodes: - - id: apply-transforms - name: Apply AST Transformations - type: automatic - steps: - - name: "Scan tsx files and apply fixes" - js-ast-grep: - js_file: scripts/codemod.ts - language: "tsx" diff --git a/codemods/v4/deprecated-dedupe-value/.gitignore b/codemods/v4/deprecated-dedupe-value/.gitignore deleted file mode 100644 index 78174f4..0000000 --- a/codemods/v4/deprecated-dedupe-value/.gitignore +++ /dev/null @@ -1,33 +0,0 @@ -# Dependencies -node_modules/ -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# Build artifacts -target/ -dist/ -build/ - -# Temporary files -*.tmp -*.temp -.cache/ - -# Environment files -.env -.env.local - -# IDE files -.vscode/ -.idea/ -*.swp -*.swo - -# OS files -.DS_Store -Thumbs.db - -# Package bundles -*.tar.gz -*.tgz \ No newline at end of file diff --git a/codemods/v4/deprecated-dedupe-value/README.md b/codemods/v4/deprecated-dedupe-value/README.md deleted file mode 100644 index 1c954a4..0000000 --- a/codemods/v4/deprecated-dedupe-value/README.md +++ /dev/null @@ -1,37 +0,0 @@ -# deprecated-dedupe-value - -Transform deprecated dedupe boolean values to string values in refresh() calls - -## Installation - -```bash -# Install from registry -npx codemod@latest run @nuxt/deprecated-dedupe-value - -# Or run locally -npx codemod@latest run -w workflow.yaml -``` - -## Usage - -This codemod transforms deprecated boolean values for the dedupe option in refresh() calls to their new string equivalents. - -### Before - -```tsx -// biome-ignore lint/correctness/useHookAtTopLevel: -await refresh({ dedupe: true }); -await refresh({ dedupe: false }); -``` - -### After - -```tsx -// biome-ignore lint/correctness/useHookAtTopLevel: -await refresh({ dedupe: "cancel" }); -await refresh({ dedupe: "defer" }); -``` - -## License - -MIT \ No newline at end of file diff --git a/codemods/v4/deprecated-dedupe-value/codemod.yaml b/codemods/v4/deprecated-dedupe-value/codemod.yaml deleted file mode 100644 index 367d67a..0000000 --- a/codemods/v4/deprecated-dedupe-value/codemod.yaml +++ /dev/null @@ -1,18 +0,0 @@ -schema_version: "1.0" - -name: "@nuxt/deprecated-dedupe-value" -version: "0.1.0" -description: "Transform deprecated dedupe values in refresh() calls" -author: Codemod -license: "MIT" -workflow: "workflow.yaml" -category: "migration" - -targets: - languages: ["tsx"] - -keywords: ["nuxt", "v4"] - -registry: - access: "public" - visibility: "public" diff --git a/codemods/v4/deprecated-dedupe-value/package.json b/codemods/v4/deprecated-dedupe-value/package.json deleted file mode 100644 index f6ca87c..0000000 --- a/codemods/v4/deprecated-dedupe-value/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "deprecated-dedupe-value", - "version": "0.1.0", - "description": "Transform deprecated dedupe values in refresh() calls", - "type": "module", - "devDependencies": { - "@codemod.com/jssg-types": "^1.0.7", - "typescript": "^5.8.3" - }, - "scripts": { - "test": "npx codemod@latest jssg test -l typescript ./scripts/codemod.ts", - "check-types": "npx tsc --noEmit" - } -} diff --git a/codemods/v4/deprecated-dedupe-value/scripts/codemod.ts b/codemods/v4/deprecated-dedupe-value/scripts/codemod.ts deleted file mode 100644 index f61c225..0000000 --- a/codemods/v4/deprecated-dedupe-value/scripts/codemod.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { SgRoot } from "codemod:ast-grep"; -import type TSX from "codemod:ast-grep/langs/tsx"; - -async function transform(root: SgRoot): Promise { - const rootNode = root.root(); - - // Find all refresh calls - const refreshCalls = rootNode.findAll({ - rule: { - pattern: "await refresh($ARGS)", - }, - }); - - const allEdits = []; - - refreshCalls.forEach((call) => { - // Find dedupe: true within this refresh call - const dedupeTrue = call.find({ - rule: { - pattern: "dedupe: true", - }, - }); - - if (dedupeTrue) { - allEdits.push(dedupeTrue.replace('dedupe: "cancel"')); - } - - // Find dedupe: false within this refresh call - const dedupeFalse = call.find({ - rule: { - pattern: "dedupe: false", - }, - }); - - if (dedupeFalse) { - allEdits.push(dedupeFalse.replace('dedupe: "defer"')); - } - }); - - if (allEdits.length === 0) { - return rootNode.text(); // No changes needed - } - - return rootNode.commitEdits(allEdits); -} - -export default transform; \ No newline at end of file diff --git a/codemods/v4/deprecated-dedupe-value/tests/fixtures/expected.ts b/codemods/v4/deprecated-dedupe-value/tests/fixtures/expected.ts deleted file mode 100644 index f4cd20a..0000000 --- a/codemods/v4/deprecated-dedupe-value/tests/fixtures/expected.ts +++ /dev/null @@ -1,3 +0,0 @@ -// biome-ignore lint/correctness/useHookAtTopLevel: -await refresh({ dedupe: "cancel" }); -await refresh({ dedupe: "defer" }); diff --git a/codemods/v4/deprecated-dedupe-value/tsconfig.json b/codemods/v4/deprecated-dedupe-value/tsconfig.json deleted file mode 100644 index 469fc5a..0000000 --- a/codemods/v4/deprecated-dedupe-value/tsconfig.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "compilerOptions": { - "module": "NodeNext", - "moduleResolution": "NodeNext", - "types": ["@codemod.com/jssg-types"], - "allowImportingTsExtensions": true, - "noEmit": true, - "verbatimModuleSyntax": true, - "erasableSyntaxOnly": true, - "strict": true, - "strictNullChecks": true, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedIndexedAccess": true - }, - "exclude": ["tests"] -} diff --git a/codemods/v4/deprecated-dedupe-value/workflow.yaml b/codemods/v4/deprecated-dedupe-value/workflow.yaml deleted file mode 100644 index 1538be7..0000000 --- a/codemods/v4/deprecated-dedupe-value/workflow.yaml +++ /dev/null @@ -1,11 +0,0 @@ -version: "1" - -nodes: - - id: apply-transforms - name: Apply AST Transformations - type: automatic - steps: - - name: "Scan tsx files and apply fixes" - js-ast-grep: - js_file: scripts/codemod.ts - language: "tsx" diff --git a/codemods/v4/file-structure/.codemodrc.json b/codemods/v4/file-structure/.codemodrc.json deleted file mode 100644 index 8aff084..0000000 --- a/codemods/v4/file-structure/.codemodrc.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "$schema": "https://codemod-utils.s3.us-west-1.amazonaws.com/configuration_schema.json", - "version": "1.0.5", - "private": true, - "name": "nuxt/4/file-structure", - "engine": "workflow", - "applicability": { - "from": [ - ["nuxt", ">=", "3.0.0"], - ["nuxt", "<", "4.0.0"] - ], - "to": [["nuxt", ">=", "4.0.0"]] - }, - "meta": { - "tags": ["nuxt", "4", "migration"], - "git": "https://github.com/codemod/nuxt-codemods/tree/main/codemods/v4/file-structure" - } -} diff --git a/codemods/v4/file-structure/.gitignore b/codemods/v4/file-structure/.gitignore deleted file mode 100644 index 76add87..0000000 --- a/codemods/v4/file-structure/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -node_modules -dist \ No newline at end of file diff --git a/codemods/v4/file-structure/LICENSE b/codemods/v4/file-structure/LICENSE deleted file mode 100644 index ff84a5b..0000000 --- a/codemods/v4/file-structure/LICENSE +++ /dev/null @@ -1,9 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2024 - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/codemods/v4/file-structure/README.md b/codemods/v4/file-structure/README.md deleted file mode 100644 index 1647c8a..0000000 --- a/codemods/v4/file-structure/README.md +++ /dev/null @@ -1,11 +0,0 @@ -Updates the file structure of a Nuxt.js project when migrating from v3 to v4. - -This codemod will migrate to the new file structure introduced in Nuxt.js v4. The new file structure is more modular and allows for better organization of the project. - -If you have any customizations related to the file structure, like `srcDir`, `serverDir`, `appDir`, `dir` - you will need to revert them back to the default values. - -This codemod will: - -1. Move `assets`, `components`, `composables`, `layouts`, `middleware`, `pages`, `plugins`, `utils` directories to the `app` directory. -2. Move `app.vue`, `error.vue`, `app.config.ts` to the `app` directory. -3. Update relative imports in the project. diff --git a/codemods/v4/file-structure/package.json b/codemods/v4/file-structure/package.json deleted file mode 100644 index 14bff8c..0000000 --- a/codemods/v4/file-structure/package.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "name": "@codemod/nuxt-4-file-structure", - "private": true, - "license": "MIT", - "devDependencies": { - "@types/node": "20.9.0", - "typescript": "^5.2.2", - "vitest": "^1.0.1", - "@codemod.com/workflow": "workspace:*" - }, - "files": [ - "README.md", - ".codemodrc.json", - "/dist/index.cjs" - ], - "type": "module" -} diff --git a/codemods/v4/file-structure/src/index.ts b/codemods/v4/file-structure/src/index.ts deleted file mode 100644 index c01bea3..0000000 --- a/codemods/v4/file-structure/src/index.ts +++ /dev/null @@ -1,89 +0,0 @@ -import type { Api } from "@codemod.com/workflow"; - -const nuxtConfigDirectoryPatterns = { - rule: { - any: [ - { - pattern: { - context: "{ srcDir: '' }", - selector: "property_identifier", - strictness: "relaxed", - }, - }, - { - pattern: { - context: "{ serverDir: '' }", - selector: "property_identifier", - strictness: "relaxed", - }, - }, - { - pattern: { - context: "{ appDir: '' }", - selector: "property_identifier", - strictness: "relaxed", - }, - }, - { - pattern: { - context: "{ dir: {} }", - selector: "property_identifier", - strictness: "relaxed", - }, - }, - { kind: "shorthand_property_identifier", regex: "^srcDir$" }, - { kind: "shorthand_property_identifier", regex: "^serverDir$" }, - { kind: "shorthand_property_identifier", regex: "^appDir$" }, - { kind: "shorthand_property_identifier", regex: "^dir$" }, - ], - }, -}; - -export async function workflow({ files, dirs, contexts }: Api) { - // Check if the nuxt.config.js/ts file has custom directory structure - const foundDirectoryPatterns = ( - await files("nuxt.config.{js,ts}") - .jsFam() - .astGrep(nuxtConfigDirectoryPatterns) - .map(({ getNode }) => ({ - filename: contexts.getFileContext().file, - text: getNode().text(), - ignore: - getNode().parent()?.parent()?.parent()?.kind() !== - "property_identifier", - })) - ).filter(({ ignore }) => !ignore); - if (foundDirectoryPatterns.length !== 0) { - console.log( - `Found ${ - foundDirectoryPatterns[0]?.filename ?? "nuxt.config.js/ts" - } file with ${foundDirectoryPatterns.map(({ text }) => text).join(", ")} set. Skipping the migration. - Automated migration is not supported for custom directory structure. Please migrate manually https://nuxt.com/docs/getting-started/upgrade. - `, - ); - return; - } - - const appDirectory = (await dirs`app`.map(({ cwd }) => cwd())).pop(); - - if (appDirectory) { - // Move directories to the new structure - await dirs` - assets - components - composables - layouts - middleware - pages - plugins - utils - `.move(appDirectory); - - // Move files to the new structure - await files` - app.vue - error.vue - app.config.{js,ts} - `.move(appDirectory); - } -} diff --git a/codemods/v4/file-structure/tsconfig.json b/codemods/v4/file-structure/tsconfig.json deleted file mode 100644 index 9811f48..0000000 --- a/codemods/v4/file-structure/tsconfig.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "compilerOptions": { - "outDir": "./dist", - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "module": "NodeNext", - "skipLibCheck": true, - "strict": true, - "target": "ES6", - "allowJs": true, - "noUncheckedIndexedAccess": true - }, - "include": [ - "./src/**/*.ts", - "./src/**/*.js", - "./test/**/*.ts", - "./test/**/*.js" - ], - "exclude": ["node_modules", "./dist/**/*"], - "ts-node": { - "transpileOnly": true - } -} diff --git a/codemods/v4/file-structure/vitest.config.ts b/codemods/v4/file-structure/vitest.config.ts deleted file mode 100644 index 772ad4c..0000000 --- a/codemods/v4/file-structure/vitest.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { configDefaults, defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - include: [...configDefaults.include, "**/test/*.ts"], - }, -}); diff --git a/codemods/v4/migration-recipe/.codemodrc.json b/codemods/v4/migration-recipe/.codemodrc.json deleted file mode 100644 index fcc2b93..0000000 --- a/codemods/v4/migration-recipe/.codemodrc.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "$schema": "https://codemod-utils.s3.us-west-1.amazonaws.com/configuration_schema.json", - "name": "nuxt/4/migration-recipe", - "version": "1.0.9", - "private": true, - "engine": "recipe", - "names": [ - "nuxt/4/absolute-watch-path", - "nuxt/4/default-data-error-value", - "nuxt/4/deprecated-dedupe-value", - "nuxt/4/file-structure", - "nuxt/4/shallow-function-reactivity", - "nuxt/4/template-compilation-changes" - ], - "meta": { - "tags": ["migration", "nuxt"], - "git": "https://github.com/codemod/nuxt-codemods/tree/main/codemods/v4/migration-recipe" - }, - "applicability": { - "from": [ - ["nuxt", ">=", "3.0.0"], - ["nuxt", "<", "4.0.0"] - ], - "to": [["nuxt", ">=", "4.0.0"]] - }, - "include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx", "**/*.vue"] - -} diff --git a/codemods/v4/migration-recipe/README.md b/codemods/v4/migration-recipe/README.md deleted file mode 100644 index 3ed92a8..0000000 --- a/codemods/v4/migration-recipe/README.md +++ /dev/null @@ -1,10 +0,0 @@ -This recipe is a set of codemods that will help migrate to Nuxt 4. - -The recipe includes the following codemods: - -- nuxt/4/absolute-watch-path -- nuxt/4/default-data-error-value -- nuxt/4/deprecated-dedupe-value -- nuxt/4/file-structure -- nuxt/4/shallow-function-reactivity -- nuxt/4/template-compilation-changes diff --git a/codemods/v4/migration-recipe/package.json b/codemods/v4/migration-recipe/package.json deleted file mode 100644 index bac0341..0000000 --- a/codemods/v4/migration-recipe/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "@codemod/nuxt-4-migration-recipe", - "files": [ - "./README.md", - "./.codemodrc.json" - ], - "type": "module", - "private": true -} diff --git a/codemods/v4/package.json b/codemods/v4/package.json new file mode 100644 index 0000000..6c69252 --- /dev/null +++ b/codemods/v4/package.json @@ -0,0 +1,16 @@ +{ + "name": "nuxt-v3-to-v4", + "version": "0.1.0", + "description": "Transform legacy code patterns", + "type": "module", + "devDependencies": { + "@codemod.com/jssg-types": "^1.0.9", + "@types/node": "^22.0.0", + "typescript": "^5.9.2", + "tsx": "^4.19.1" + }, + "scripts": { + "test": "tsx comprehensive-test-runner.ts", + "check-types": "npx tsc --noEmit" + } +} diff --git a/codemods/v4/scripts/absolute-watch-path.ts b/codemods/v4/scripts/absolute-watch-path.ts new file mode 100644 index 0000000..0a18721 --- /dev/null +++ b/codemods/v4/scripts/absolute-watch-path.ts @@ -0,0 +1,141 @@ +import type { SgRoot, Edit } from "codemod:ast-grep"; +import type TSX from "codemod:ast-grep/langs/tsx"; +import type HTML from "codemod:ast-grep/langs/html"; +import { hasContent } from "../utils/index.ts"; +import { ensureImport } from "../utils/imports.ts"; + +// Helper function that contains the core transformation logic +// This works on any TypeScript AST (from .ts files or extracted from .vue files) +//TODO: "any" type casting should be fixed/replaced. +function transformTypeScriptAST(tsRootNode: any, tsRoot: SgRoot): Edit[] { + const edits: Edit[] = []; + let needsImport = false; + + // Find nuxt.hook calls with "builder:watch" as first argument + const hookCalls = tsRootNode.findAll({ + rule: { + pattern: 'nuxt.hook("builder:watch", $CALLBACK)', + }, + }); + + for (const hookCall of hookCalls) { + const callback = hookCall.getMatch("CALLBACK"); + + if (!callback) continue; + + // Check if it's an arrow function - if not, skip for now + if (!callback.is("arrow_function")) continue; + + // Skip if the hook already has path normalization (avoid re-transforming) + const hookText = hookCall.text(); + if (hookText.includes("relative(") && hookText.includes("resolve(")) { + //TODO string op should be replaced + continue; + } + + // Get the parameters + const params = callback.field("parameters"); + if (!params) continue; + + // Find the parameter identifiers + const paramIdentifiers = params.findAll({ + rule: { kind: "identifier" }, + }); + + if (paramIdentifiers.length !== 2) continue; + + const pathParam = paramIdentifiers[1]; // Second parameter + if (!pathParam) continue; + const pathParamName = pathParam.text(); + + // Get the function body + const body = callback.field("body"); + if (!body) continue; + + // Check if the function is async + const isAsync = callback.text().includes("async"); + + if (body.is("statement_block")) { + // Function has a block body - insert path normalization at the beginning + const asyncKeyword = isAsync ? "async " : ""; + const bodyText = body.text(); + + // Create replacement with path normalization added at the beginning of the block + const bodyContent = bodyText.slice(1, -1).trim(); + const replacement = `nuxt.hook("builder:watch", ${asyncKeyword}(event, ${pathParamName}) => { + ${pathParamName} = relative(nuxt.options.srcDir, resolve(nuxt.options.srcDir, ${pathParamName})); + ${bodyContent} +})`; + + edits.push(hookCall.replace(replacement)); + needsImport = true; + } else { + // For expression bodies, replace with block statement + const bodyText = body.text(); + const asyncKeyword = isAsync ? "async " : ""; + const replacement = `nuxt.hook("builder:watch", ${asyncKeyword}(event, ${pathParamName}) => { + ${pathParamName} = relative( + nuxt.options.srcDir, + resolve(nuxt.options.srcDir, ${pathParamName}) + ); + return ${bodyText}; +})`; + + edits.push(hookCall.replace(replacement)); + needsImport = true; + } + } + + // Handle imports - add import BEFORE other edits + //TODO: the import utils should be updated to be able to handle this. + if (needsImport && edits.length > 0) { + // Check if import already exists in the original code using AST patterns + const hasNodePathImport = + tsRoot.root().findAll({ + rule: { + pattern: 'import { $IMPORTS } from "node:path"', + }, + }).length > 0 || + tsRoot.root().findAll({ + rule: { + pattern: "import { $IMPORTS } from 'node:path'", + }, + }).length > 0; + + if (!hasNodePathImport) { + // Add import using ensureImport on the ORIGINAL ast + const importResult = ensureImport(tsRoot.root() as any, "node:path", [ + { type: "named", name: "relative", typed: false }, + { type: "named", name: "resolve", typed: false }, + ]); + + if ( + importResult.edit.insertedText && + importResult.edit.insertedText.trim() + ) { + // Prepend the import edit to the list of edits + edits.unshift(importResult.edit); + } + } + } + + return edits; +} + +async function transform(root: SgRoot): Promise { + const rootNode = root.root(); + + // Quick check - does file contain nuxt.hook calls? + if (!hasContent(root, "nuxt.hook")) { + return null; + } + const edits = transformTypeScriptAST(rootNode as any, root as SgRoot); + + if (edits.length === 0) { + return null; + } + + return rootNode.commitEdits(edits); +} + +export default transform; diff --git a/codemods/v4/default-data-error-value/scripts/codemod.ts b/codemods/v4/scripts/default-data-error-value.ts similarity index 60% rename from codemods/v4/default-data-error-value/scripts/codemod.ts rename to codemods/v4/scripts/default-data-error-value.ts index e8a68bc..70905c3 100644 --- a/codemods/v4/default-data-error-value/scripts/codemod.ts +++ b/codemods/v4/scripts/default-data-error-value.ts @@ -1,22 +1,26 @@ -import type { SgRoot } from "codemod:ast-grep"; +import type { SgRoot, Edit } from "codemod:ast-grep"; import type TSX from "codemod:ast-grep/langs/tsx"; +import type HTML from "codemod:ast-grep/langs/html"; +import { DATA_FETCH_HOOKS } from "../utils/index.ts"; -async function transform(root: SgRoot): Promise { +async function transform(root: SgRoot): Promise { const rootNode = root.root(); // Extract data and error variable names from destructuring const dataErrorVars = new Set(); - - // Find all const declarations that assign to useAsyncData or useFetch + // Find all const declarations that assign to data fetch hooks const constDeclarations = rootNode.findAll({ - rule: { - pattern: "const $DECL = $HOOK($$$ARGS)", - }, + rule: { pattern: "const $DECL = $HOOK($$$ARGS)" }, }); constDeclarations.forEach((decl) => { const hook = decl.getMatch("HOOK"); - if (hook?.text() === "useAsyncData" || hook?.text() === "useFetch") { + if ( + hook && + DATA_FETCH_HOOKS.includes( + hook.text() as (typeof DATA_FETCH_HOOKS)[number] + ) + ) { const declPattern = decl.getMatch("DECL"); if (declPattern?.is("object_pattern")) { // Get all children of the object pattern to find properties @@ -47,35 +51,32 @@ async function transform(root: SgRoot): Promise { // Find all null comparisons with our data/error variables const nullComparisons = rootNode.findAll({ - rule: { - pattern: "$VAR.value === null", - }, + rule: { pattern: "$VAR.value === null" }, }); - const edits = nullComparisons.map((comparison) => { - const varNode = comparison.getMatch("VAR"); - if (varNode?.is("identifier")) { - const varName = varNode.text(); - if (dataErrorVars.has(varName)) { - // Replace null with undefined - const nullNode = comparison.find({ - rule: { - pattern: "null", - }, - }); - if (nullNode) { - return nullNode.replace("undefined"); + const edits = nullComparisons + .map((comparison) => { + const varNode = comparison.getMatch("VAR"); + if (varNode?.is("identifier")) { + const varName = varNode.text(); + if (dataErrorVars.has(varName)) { + // Replace null with undefined + const nullNode = comparison.find({ + rule: { pattern: "null" }, + }); + if (nullNode) { + return nullNode.replace("undefined"); + } } } - } - return null; - }).filter(Boolean); + return null; + }) + .filter((edit): edit is Edit => edit !== null); if (edits.length === 0) { - return rootNode.text(); // No changes needed + return null; } - return rootNode.commitEdits(edits); } -export default transform; \ No newline at end of file +export default transform; diff --git a/codemods/v4/scripts/deprecated-dedupe-value.ts b/codemods/v4/scripts/deprecated-dedupe-value.ts new file mode 100644 index 0000000..95060b6 --- /dev/null +++ b/codemods/v4/scripts/deprecated-dedupe-value.ts @@ -0,0 +1,44 @@ +import type { SgRoot, Edit } from "codemod:ast-grep"; +import type TSX from "codemod:ast-grep/langs/tsx"; +import type HTML from "codemod:ast-grep/langs/html"; +import { hasContent, replaceInNode } from "../utils/index.ts"; + +async function transform(root: SgRoot): Promise { + const rootNode = root.root(); + + // Quick check - does file contain refresh calls? + if (!hasContent(root, "refresh")) { + return null; + } + + // Find all refresh calls + const refreshCalls = rootNode.findAll({ + rule: { pattern: "await refresh($ARGS)" }, + }); + + if (refreshCalls.length === 0) { + return null; + } + + const edits: Edit[] = []; + + refreshCalls.forEach((call) => { + // Use utility for regex replacement + const trueEdit = replaceInNode(call, /dedupe:\s*true/g, 'dedupe: "cancel"'); + const falseEdit = replaceInNode( + call, + /dedupe:\s*false/g, + 'dedupe: "defer"' + ); + + if (trueEdit) edits.push(trueEdit); + if (falseEdit) edits.push(falseEdit); + }); + + if (edits.length === 0) { + return null; + } + return rootNode.commitEdits(edits); +} + +export default transform; diff --git a/codemods/v4/scripts/shallow-function-reactivity.ts b/codemods/v4/scripts/shallow-function-reactivity.ts new file mode 100644 index 0000000..1070d9e --- /dev/null +++ b/codemods/v4/scripts/shallow-function-reactivity.ts @@ -0,0 +1,41 @@ +import type { SgRoot, Edit } from "codemod:ast-grep"; +import type TSX from "codemod:ast-grep/langs/tsx"; +import type HTML from "codemod:ast-grep/langs/html"; +import { hasAnyContent, applyEdits, DATA_FETCH_HOOKS } from "../utils/index.ts"; + +async function transform(root: SgRoot): Promise { + const rootNode = root.root(); + + // Quick check - does file contain data fetching hooks? + if (!hasAnyContent(root, DATA_FETCH_HOOKS)) { + return null; + } + + const allEdits: Edit[] = []; + + DATA_FETCH_HOOKS.forEach((hookName) => { + // Find all calls to this hook with single argument (function only) + const singleArgCalls = rootNode.findAll({ + rule: { + pattern: `${hookName}($ARG)`, + }, + }); + + singleArgCalls.forEach((call) => { + const arg = call.getMatch("ARG"); + if (arg) { + // Check if it's a single argument (not an object) + if (!arg.is("object")) { + // Single argument - add options with deep: true + allEdits.push( + call.replace(`${hookName}(${arg.text()}, { deep: true })`) + ); + } + } + }); + }); + + return applyEdits(rootNode, allEdits); +} + +export default transform; diff --git a/codemods/v4/scripts/template-compilation-changes.ts b/codemods/v4/scripts/template-compilation-changes.ts new file mode 100644 index 0000000..0b790a7 --- /dev/null +++ b/codemods/v4/scripts/template-compilation-changes.ts @@ -0,0 +1,119 @@ +import type { SgRoot, Edit } from "codemod:ast-grep"; +import type TS from "codemod:ast-grep/langs/typescript"; +import type HTML from "codemod:ast-grep/langs/html"; +import { hasContent } from "../utils/index.ts"; +import { ensureImport } from "../utils/imports.ts"; + +async function transform(root: SgRoot): Promise { + const rootNode = root.root(); + + // Quick check - does file contain addTemplate calls? + if (!hasContent(root, "addTemplate")) { + return null; + } + + const edits: Edit[] = []; + let needsReadFileSync = false; + let needsTemplate = false; + + // Find all addTemplate call expressions + const callExpressions = rootNode.findAll({ + rule: { kind: "call_expression" }, + }); + + for (const call of callExpressions) { + const func = call.field("function"); + if (!func || func.text() !== "addTemplate") continue; + + // Get the arguments + const args = call.field("arguments"); + if (!args) continue; + + // Find the object argument + const obj = args.find({ + rule: { kind: "object" }, + }); + + if (!obj) continue; + + // Find all pairs in the object + const pairs = obj.findAll({ + rule: { kind: "pair" }, + }); + + for (const pair of pairs) { + const key = pair.field("key"); + const value = pair.field("value"); + + if (!key || !value || key.text() !== "src") continue; + + // Check if the value contains .ejs + if (!value.text().includes(".ejs")) continue; + + // Extract the path from resolver.resolve call + const resolverCall = value.find({ + rule: { pattern: "resolver.resolve($PATH)" }, + }); + + if (!resolverCall) continue; + + const pathArg = resolverCall.getMatch("PATH"); + if (!pathArg) continue; + + const pathText = pathArg.text(); + + // Mark that we need imports + needsReadFileSync = true; + needsTemplate = true; + + // Replace the entire src property with getContents method + const getContentsMethod = `getContents({ options }) { + const contents = readFileSync( + resolver.resolve(${pathText}), + "utf-8" + ); + + return template(contents)({ + options, + }); + }`; + + edits.push(pair.replace(getContentsMethod)); + } + } + + // Add imports if needed + if (needsReadFileSync) { + const importResult = ensureImport(rootNode as any, "node:fs", [ + { type: "named", name: "readFileSync", typed: false }, + ]); + + if ( + importResult.edit.insertedText && + importResult.edit.insertedText.trim() + ) { + edits.unshift(importResult.edit); + } + } + + if (needsTemplate) { + const importResult = ensureImport(rootNode as any, "lodash-es", [ + { type: "named", name: "template", typed: false }, + ]); + + if ( + importResult.edit.insertedText && + importResult.edit.insertedText.trim() + ) { + edits.unshift(importResult.edit); + } + } + + if (edits.length === 0) { + return null; + } + + return rootNode.commitEdits(edits); +} + +export default transform; diff --git a/codemods/v4/shallow-function-reactivity/.gitignore b/codemods/v4/shallow-function-reactivity/.gitignore deleted file mode 100644 index 78174f4..0000000 --- a/codemods/v4/shallow-function-reactivity/.gitignore +++ /dev/null @@ -1,33 +0,0 @@ -# Dependencies -node_modules/ -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# Build artifacts -target/ -dist/ -build/ - -# Temporary files -*.tmp -*.temp -.cache/ - -# Environment files -.env -.env.local - -# IDE files -.vscode/ -.idea/ -*.swp -*.swo - -# OS files -.DS_Store -Thumbs.db - -# Package bundles -*.tar.gz -*.tgz \ No newline at end of file diff --git a/codemods/v4/shallow-function-reactivity/README.md b/codemods/v4/shallow-function-reactivity/README.md deleted file mode 100644 index fca5886..0000000 --- a/codemods/v4/shallow-function-reactivity/README.md +++ /dev/null @@ -1,35 +0,0 @@ -# shallow-function-reactivity - -Add deep: true option to useLazyAsyncData, useAsyncData, useFetch, and useLazyFetch calls - -## Installation - -```bash -# Install from registry -npx codemod@latest run @nuxt/shallow-function-reactivity - -# Or run locally -npx codemod@latest run -w workflow.yaml -``` - -## Usage - -This codemod adds the deep: true option to Nuxt composables to ensure proper reactivity for function-based data. - -### Before - -```tsx -// biome-ignore lint/correctness/useHookAtTopLevel: -const { data } = useLazyAsyncData("/api/test"); -``` - -### After - -```tsx -// biome-ignore lint/correctness/useHookAtTopLevel: -const { data } = useLazyAsyncData("/api/test", { deep: true }); -``` - -## License - -MIT \ No newline at end of file diff --git a/codemods/v4/shallow-function-reactivity/codemod.yaml b/codemods/v4/shallow-function-reactivity/codemod.yaml deleted file mode 100644 index 6d9ce7a..0000000 --- a/codemods/v4/shallow-function-reactivity/codemod.yaml +++ /dev/null @@ -1,18 +0,0 @@ -schema_version: "1.0" - -name: "@nuxt/shallow-function-reactivity" -version: "0.1.0" -description: "Add deep: true to useLazyAsyncData, useAsyncData, useFetch, and useLazyFetch calls" -author: Codemod -license: "MIT" -workflow: "workflow.yaml" -category: "migration" - -targets: - languages: ["tsx"] - -keywords: ["nuxt", "v4"] - -registry: - access: "public" - visibility: "public" diff --git a/codemods/v4/shallow-function-reactivity/package.json b/codemods/v4/shallow-function-reactivity/package.json deleted file mode 100644 index dfa84af..0000000 --- a/codemods/v4/shallow-function-reactivity/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "shallow-function-reactivity", - "version": "0.1.0", - "description": "Add deep: true to useLazyAsyncData, useAsyncData, useFetch, and useLazyFetch calls", - "type": "module", - "devDependencies": { - "@codemod.com/jssg-types": "^1.0.7", - "typescript": "^5.8.3" - }, - "scripts": { - "test": "npx codemod@latest jssg test -l typescript ./scripts/codemod.ts", - "check-types": "npx tsc --noEmit" - } -} diff --git a/codemods/v4/shallow-function-reactivity/scripts/codemod.ts b/codemods/v4/shallow-function-reactivity/scripts/codemod.ts deleted file mode 100644 index 9b12926..0000000 --- a/codemods/v4/shallow-function-reactivity/scripts/codemod.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { SgRoot } from "codemod:ast-grep"; -import type TSX from "codemod:ast-grep/langs/tsx"; - -async function transform(root: SgRoot): Promise { - const rootNode = root.root(); - - // Find all useLazyAsyncData, useAsyncData, useFetch, and useLazyFetch calls - const hooks = [ - "useLazyAsyncData", - "useAsyncData", - "useFetch", - "useLazyFetch" - ]; - - const allEdits = []; - - hooks.forEach((hookName) => { - // Find all calls to this hook - const hookCalls = rootNode.findAll({ - rule: { - pattern: `${hookName}($ARGS)`, - }, - }); - - hookCalls.forEach((call) => { - const args = call.getMatch("ARGS"); - if (args) { - // Check if it's a single argument (not an object) - if (!args.is("object")) { - // Single argument - add options with deep: true - allEdits.push(call.replace(`${hookName}(${args.text()}, { deep: true })`)); - } - } - }); - - // Also find calls with two arguments - const twoArgCalls = rootNode.findAll({ - rule: { - pattern: `${hookName}($ARG1, $ARG2)`, - }, - }); - - twoArgCalls.forEach((call) => { - const arg1 = call.getMatch("ARG1"); - const arg2 = call.getMatch("ARG2"); - - if (arg2 && arg2.is("object")) { - // Check if deep property already exists - const deepProperty = arg2.find({ - rule: { - pattern: "deep: $VALUE", - }, - }); - - if (!deepProperty) { - // Add deep: true to existing options - const optionsText = arg2.text(); - if (optionsText === "{}") { - // Empty object - allEdits.push(arg2.replace(`{ deep: true }`)); - } else { - // Non-empty object - add before closing brace - allEdits.push(arg2.replace(`${optionsText.slice(0, -1)}, deep: true }`)); - } - } - } - }); - }); - - if (allEdits.length === 0) { - return rootNode.text(); // No changes needed - } - - return rootNode.commitEdits(allEdits); -} - -export default transform; \ No newline at end of file diff --git a/codemods/v4/shallow-function-reactivity/tests/fixtures/expected.ts b/codemods/v4/shallow-function-reactivity/tests/fixtures/expected.ts deleted file mode 100644 index 45a57e7..0000000 --- a/codemods/v4/shallow-function-reactivity/tests/fixtures/expected.ts +++ /dev/null @@ -1,2 +0,0 @@ -// biome-ignore lint/correctness/useHookAtTopLevel: -const { data } = useLazyAsyncData("/api/test", { deep: true , deep: true , deep: true , deep: true , deep: true }); diff --git a/codemods/v4/shallow-function-reactivity/tsconfig.json b/codemods/v4/shallow-function-reactivity/tsconfig.json deleted file mode 100644 index 469fc5a..0000000 --- a/codemods/v4/shallow-function-reactivity/tsconfig.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "compilerOptions": { - "module": "NodeNext", - "moduleResolution": "NodeNext", - "types": ["@codemod.com/jssg-types"], - "allowImportingTsExtensions": true, - "noEmit": true, - "verbatimModuleSyntax": true, - "erasableSyntaxOnly": true, - "strict": true, - "strictNullChecks": true, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedIndexedAccess": true - }, - "exclude": ["tests"] -} diff --git a/codemods/v4/shallow-function-reactivity/workflow.yaml b/codemods/v4/shallow-function-reactivity/workflow.yaml deleted file mode 100644 index 1538be7..0000000 --- a/codemods/v4/shallow-function-reactivity/workflow.yaml +++ /dev/null @@ -1,11 +0,0 @@ -version: "1" - -nodes: - - id: apply-transforms - name: Apply AST Transformations - type: automatic - steps: - - name: "Scan tsx files and apply fixes" - js-ast-grep: - js_file: scripts/codemod.ts - language: "tsx" diff --git a/codemods/v4/template-compilation-changes/.codemodrc.json b/codemods/v4/template-compilation-changes/.codemodrc.json deleted file mode 100644 index 184a6a3..0000000 --- a/codemods/v4/template-compilation-changes/.codemodrc.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "$schema": "https://codemod-utils.s3.us-west-1.amazonaws.com/configuration_schema.json", - "version": "1.0.6", - "private": true, - "name": "nuxt/4/template-compilation-changes", - "engine": "jscodeshift", - "applicability": { - "from": [ - ["nuxt", ">=", "3.0.0"], - ["nuxt", "<", "4.0.0"] - ], - "to": [["nuxt", ">=", "4.0.0"]] - }, - "meta": { - "tags": ["nuxt", "4", "migration"], - "git": "https://github.com/codemod/nuxt-codemods/tree/main/codemods/v4/template-compilation-changes" - }, - "include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx", "**/*.vue"] -} diff --git a/codemods/v4/template-compilation-changes/.gitignore b/codemods/v4/template-compilation-changes/.gitignore deleted file mode 100644 index 76add87..0000000 --- a/codemods/v4/template-compilation-changes/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -node_modules -dist \ No newline at end of file diff --git a/codemods/v4/template-compilation-changes/LICENSE b/codemods/v4/template-compilation-changes/LICENSE deleted file mode 100644 index 8695657..0000000 --- a/codemods/v4/template-compilation-changes/LICENSE +++ /dev/null @@ -1,9 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2024 Codemod Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/codemods/v4/template-compilation-changes/README.md b/codemods/v4/template-compilation-changes/README.md deleted file mode 100644 index 3235b23..0000000 --- a/codemods/v4/template-compilation-changes/README.md +++ /dev/null @@ -1,44 +0,0 @@ -This codemod removes lodash/template and related template utilities from Nuxt in favor of a more flexible and secure getContents() function for code generation in v3. - -### What Changed - -Previously, Nuxt used `lodash/template` to compile templates located on the file system using the `.ejs` file format/syntax. Additionally, Nuxt provided some template utilities (`serialize`, `importName`, `importSources`) for code generation within these templates. These utilities are now being removed. - -### Reasons for Change - -In Nuxt v3, we moved to a 'virtual' syntax with a `getContents()` function, which is much more flexible and performant. Additionally, `lodash/template` has had multiple security issues. Although these issues do not apply to Nuxt projects since it is used at build-time and by trusted code, they still appear in security audits. Moreover, `lodash` is a hefty dependency and is unused by most projects. - -### Before - -```js -addTemplate({ - fileName: "appinsights-vue.js", - options: { - /* some options */ - }, - src: resolver.resolve("./runtime/plugin.ejs"), -}); -``` - -### After - -```js -import { template } from "lodash-es"; -import { readFileSync } from "node:fs"; - -addTemplate({ - fileName: "appinsights-vue.js", - options: { - /* some options */ - }, - getContents({ options }) { - const contents = readFileSync( - resolver.resolve("./runtime/plugin.ejs"), - "utf-8", - ); - return template(contents)({ options }); - }, -}); -``` - -> This change applies to all templates using .ejs file format/syntax. diff --git a/codemods/v4/template-compilation-changes/__testfixtures__/fixture1.input.ts b/codemods/v4/template-compilation-changes/__testfixtures__/fixture1.input.ts deleted file mode 100644 index d10f5ae..0000000 --- a/codemods/v4/template-compilation-changes/__testfixtures__/fixture1.input.ts +++ /dev/null @@ -1,7 +0,0 @@ -addTemplate({ - fileName: "appinsights-vue.js", - options: { - /* some options */ - }, - src: resolver.resolve("./runtime/plugin.ejs"), -}); diff --git a/codemods/v4/template-compilation-changes/__testfixtures__/fixture1.output.ts b/codemods/v4/template-compilation-changes/__testfixtures__/fixture1.output.ts deleted file mode 100644 index f6b0512..0000000 --- a/codemods/v4/template-compilation-changes/__testfixtures__/fixture1.output.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { template } from "lodash-es"; -import { readFileSync } from "node:fs"; -addTemplate({ - fileName: "appinsights-vue.js", - - options: { - /* some options */ - }, - - getContents({ options: options }) { - const contents = readFileSync( - resolver.resolve("./runtime/plugin.ejs"), - "utf-8", - ); - - return template(contents)({ - options: options, - }); - }, -}); diff --git a/codemods/v4/template-compilation-changes/__testfixtures__/fixture2.input.ts b/codemods/v4/template-compilation-changes/__testfixtures__/fixture2.input.ts deleted file mode 100644 index 20e4829..0000000 --- a/codemods/v4/template-compilation-changes/__testfixtures__/fixture2.input.ts +++ /dev/null @@ -1,7 +0,0 @@ -addTemplate({ - fileName: "appinsights-vue.js", - options: { - /* some options */ - }, - src: resolver.resolve("./runtime/plugin.ts"), -}); diff --git a/codemods/v4/template-compilation-changes/__testfixtures__/fixture2.output.ts b/codemods/v4/template-compilation-changes/__testfixtures__/fixture2.output.ts deleted file mode 100644 index 20e4829..0000000 --- a/codemods/v4/template-compilation-changes/__testfixtures__/fixture2.output.ts +++ /dev/null @@ -1,7 +0,0 @@ -addTemplate({ - fileName: "appinsights-vue.js", - options: { - /* some options */ - }, - src: resolver.resolve("./runtime/plugin.ts"), -}); diff --git a/codemods/v4/template-compilation-changes/package.json b/codemods/v4/template-compilation-changes/package.json deleted file mode 100644 index b4f5c6a..0000000 --- a/codemods/v4/template-compilation-changes/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "@codemod/nuxt-4-template-compilation-changes", - "private": true, - "dependencies": {}, - "devDependencies": { - "typescript": "^5.2.2", - "ts-node": "^10.9.1", - "jscodeshift": "^0.15.1", - "@types/jscodeshift": "^0.11.10", - "vitest": "^1.0.1", - "@vitest/coverage-v8": "^1.0.1" - }, - "scripts": {}, - "files": [ - "./README.md", - "./.codemodrc.json", - "./dist/index.cjs" - ], - "type": "module" -} diff --git a/codemods/v4/template-compilation-changes/src/index.ts b/codemods/v4/template-compilation-changes/src/index.ts deleted file mode 100644 index deab526..0000000 --- a/codemods/v4/template-compilation-changes/src/index.ts +++ /dev/null @@ -1,132 +0,0 @@ -import type { API, FileInfo, Options } from "jscodeshift"; - -export default function transform( - file: FileInfo, - api: API, - options?: Options, -): string | undefined { - const j = api.jscodeshift; - const root = j(file.source); - let isDirty = false; - - // Add the necessary import statements - const importStatements = [ - j.importDeclaration( - [j.importSpecifier(j.identifier("readFileSync"))], - j.literal("node:fs"), - ), - j.importDeclaration( - [j.importSpecifier(j.identifier("template"))], - j.literal("lodash-es"), - ), - ]; - - // Find and replace the `src` property within the `addTemplate` function call - root - .find(j.CallExpression, { - callee: { - type: "Identifier", - name: "addTemplate", - }, - }) - .forEach((path) => { - const args = path.node.arguments; - if (args.length == 1 && j.ObjectExpression.check(args[0])) { - const properties = args[0].properties; - const srcPropertyIndex = properties.findIndex( - (prop) => - prop.type === "ObjectProperty" && - prop.key.type === "Identifier" && - prop.key.name === "src" && - prop.value.type === "CallExpression" && - prop.value.callee.type === "MemberExpression" && - prop.value.callee.object.type === "Identifier" && - prop.value.callee.object.name === "resolver" && - prop.value.callee.property.type === "Identifier" && - prop.value.callee.property.name === "resolve" && - prop.value.arguments.some( - (arg) => - arg.type === "StringLiteral" && - (arg.value as string).endsWith(".ejs"), - ), - ); - if (srcPropertyIndex !== -1) { - // find the value of the .ejs template path in the src - const pathLiteral = properties[srcPropertyIndex].value.arguments.find( - (arg: any) => - j.StringLiteral.check(arg) && - (arg.value as string).endsWith(".ejs"), - ).value; - - // Remove the src property - properties.splice(srcPropertyIndex, 1); - - // Add the getContents function - properties.push( - j.objectMethod( - "method", - j.identifier("getContents"), - [ - j.objectPattern([ - j.objectProperty( - j.identifier("options"), - j.identifier("options"), - ), - ]), - ], - j.blockStatement([ - j.variableDeclaration("const", [ - j.variableDeclarator( - j.identifier("contents"), - j.callExpression(j.identifier("readFileSync"), [ - j.callExpression( - j.memberExpression( - j.identifier("resolver"), - j.identifier("resolve"), - ), - [j.literal(pathLiteral)], - ), - j.literal("utf-8"), - ]), - ), - ]), - j.returnStatement( - j.callExpression( - j.callExpression(j.identifier("template"), [ - j.identifier("contents"), - ]), - [ - j.objectExpression([ - j.objectProperty( - j.identifier("options"), - j.identifier("options"), - ), - ]), - ], - ), - ), - ]), - ), - ); - - isDirty = true; - - // Add the import statements if they are not already present - const existingImportDeclarations = root.find(j.ImportDeclaration); - importStatements.forEach((importStatement) => { - const isAlreadyImported = - existingImportDeclarations.filter((path) => { - return path.node.source.value === importStatement.source.value; - }).length > 0; - - if (!isAlreadyImported) { - root.get().node.program.body.unshift(importStatement); - isDirty = true; - } - }); - } - } - }); - - return isDirty ? root.toSource() : undefined; -} diff --git a/codemods/v4/template-compilation-changes/tsconfig.json b/codemods/v4/template-compilation-changes/tsconfig.json deleted file mode 100644 index 9811f48..0000000 --- a/codemods/v4/template-compilation-changes/tsconfig.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "compilerOptions": { - "outDir": "./dist", - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "module": "NodeNext", - "skipLibCheck": true, - "strict": true, - "target": "ES6", - "allowJs": true, - "noUncheckedIndexedAccess": true - }, - "include": [ - "./src/**/*.ts", - "./src/**/*.js", - "./test/**/*.ts", - "./test/**/*.js" - ], - "exclude": ["node_modules", "./dist/**/*"], - "ts-node": { - "transpileOnly": true - } -} diff --git a/codemods/v4/template-compilation-changes/vitest.config.ts b/codemods/v4/template-compilation-changes/vitest.config.ts deleted file mode 100644 index 772ad4c..0000000 --- a/codemods/v4/template-compilation-changes/vitest.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { configDefaults, defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - include: [...configDefaults.include, "**/test/*.ts"], - }, -}); diff --git a/codemods/v4/tests/absolute-watch-path/expected.ts b/codemods/v4/tests/absolute-watch-path/expected.ts new file mode 100644 index 0000000..5d6a5e3 --- /dev/null +++ b/codemods/v4/tests/absolute-watch-path/expected.ts @@ -0,0 +1,37 @@ +// Test Case 1: Basic arrow function with block statement +nuxt.hook("builder:watch", (event, path) => { + path = relative(nuxt.options.srcDir, resolve(nuxt.options.srcDir, path)); + someFunction(); + console.log("Processing:", path); +}); + +// Test Case 2: Arrow function without block statement +nuxt.hook("builder:watch", async (event, filePath) => { + filePath = relative( + nuxt.options.srcDir, + resolve(nuxt.options.srcDir, filePath) + ); + return console.log("File changed:", filePath); +}); + +// Test Case 3: Existing node:fs import with other specifiers +import { readFile } from "node:fs"; +import { relative, resolve } from "node:path"; + +nuxt.hook("builder:watch", (event, watchedPath) => { + watchedPath = relative( + nuxt.options.srcDir, + resolve(nuxt.options.srcDir, watchedPath) + ); + readFile(watchedPath, "utf8", callback); +}); + +// Test Case 4: Regular function (should not be transformed) +nuxt.hook("builder:watch", function (event, path) { + processFile(path); +}); + +// Test Case 5: Hook with different event name (should not be transformed) +nuxt.hook("other:event", (event, path) => { + doSomething(path); +}); diff --git a/codemods/v4/tests/absolute-watch-path/input.ts b/codemods/v4/tests/absolute-watch-path/input.ts new file mode 100644 index 0000000..855358a --- /dev/null +++ b/codemods/v4/tests/absolute-watch-path/input.ts @@ -0,0 +1,27 @@ +// Test Case 1: Basic arrow function with block statement +nuxt.hook("builder:watch", (event, path) => { + someFunction(); + console.log("Processing:", path); +}); + +// Test Case 2: Arrow function without block statement +nuxt.hook("builder:watch", async (event, filePath) => + console.log("File changed:", filePath) +); + +// Test Case 3: Existing node:fs import with other specifiers +import { readFile } from "node:fs"; + +nuxt.hook("builder:watch", (event, watchedPath) => { + readFile(watchedPath, "utf8", callback); +}); + +// Test Case 4: Regular function (should not be transformed) +nuxt.hook("builder:watch", function (event, path) { + processFile(path); +}); + +// Test Case 5: Hook with different event name (should not be transformed) +nuxt.hook("other:event", (event, path) => { + doSomething(path); +}); diff --git a/codemods/v4/tests/default-data-error-value/expected.ts b/codemods/v4/tests/default-data-error-value/expected.ts new file mode 100644 index 0000000..8427f4d --- /dev/null +++ b/codemods/v4/tests/default-data-error-value/expected.ts @@ -0,0 +1,34 @@ +const { data: userData, error } = useAsyncData( + () => client.value.v1.users.fetch(), + { + default: () => shallowRef(), + } +); + +const { data: listData, error: listError } = useFetch( + () => client.value.v1.lists.fetch(), + { + default: () => shallowRef(), + } +); + +if (userData.value === undefined) { + if (listData.value === undefined) { + if (error.value === undefined) { + // Something + } else if (listError.value === undefined) { + // Something else + } + } +} + +let x = + userData.value === undefined + ? "Hello" + : error.value === undefined + ? "Morning" + : listError.value === undefined + ? "Hello" + : listData.value === undefined + ? "Morning" + : "Night"; diff --git a/codemods/v4/tests/default-data-error-value/input.ts b/codemods/v4/tests/default-data-error-value/input.ts new file mode 100644 index 0000000..9439ce3 --- /dev/null +++ b/codemods/v4/tests/default-data-error-value/input.ts @@ -0,0 +1,34 @@ +const { data: userData, error } = useAsyncData( + () => client.value.v1.users.fetch(), + { + default: () => shallowRef(), + } +); + +const { data: listData, error: listError } = useFetch( + () => client.value.v1.lists.fetch(), + { + default: () => shallowRef(), + } +); + +if (userData.value === null) { + if (listData.value === null) { + if (error.value === null) { + // Something + } else if (listError.value === null) { + // Something else + } + } +} + +let x = + userData.value === null + ? "Hello" + : error.value === null + ? "Morning" + : listError.value === null + ? "Hello" + : listData.value === null + ? "Morning" + : "Night"; diff --git a/codemods/v4/tests/deprecated-dedupe-value/expected.ts b/codemods/v4/tests/deprecated-dedupe-value/expected.ts new file mode 100644 index 0000000..0457f09 --- /dev/null +++ b/codemods/v4/tests/deprecated-dedupe-value/expected.ts @@ -0,0 +1,10 @@ +// Test case: refresh calls with dedupe: true that should be transformed +await refresh({ dedupe: "cancel" }); + +await refresh({ + dedupe: "cancel", + other: "option", +}); + +// Test case: refresh calls with dedupe: false that should be transformed +await refresh({ dedupe: "defer" }); diff --git a/codemods/v4/tests/deprecated-dedupe-value/input.ts b/codemods/v4/tests/deprecated-dedupe-value/input.ts new file mode 100644 index 0000000..7591f4a --- /dev/null +++ b/codemods/v4/tests/deprecated-dedupe-value/input.ts @@ -0,0 +1,10 @@ +// Test case: refresh calls with dedupe: true that should be transformed +await refresh({ dedupe: true }); + +await refresh({ + dedupe: true, + other: "option", +}); + +// Test case: refresh calls with dedupe: false that should be transformed +await refresh({ dedupe: false }); diff --git a/codemods/v4/tests/import-utils/case-1-add-empty-file/expected.ts b/codemods/v4/tests/import-utils/case-1-add-empty-file/expected.ts new file mode 100644 index 0000000..af6bba4 --- /dev/null +++ b/codemods/v4/tests/import-utils/case-1-add-empty-file/expected.ts @@ -0,0 +1,6 @@ +import { resolve, join } from "node:path"; +// CASE 1: ADD - Empty file with no imports +// Expected: Should add new import statement at the top + +console.log("Hello World"); +someFunction(); diff --git a/codemods/v4/tests/import-utils/case-1-add-empty-file/input.ts b/codemods/v4/tests/import-utils/case-1-add-empty-file/input.ts new file mode 100644 index 0000000..1a47ad7 --- /dev/null +++ b/codemods/v4/tests/import-utils/case-1-add-empty-file/input.ts @@ -0,0 +1,5 @@ +// CASE 1: ADD - Empty file with no imports +// Expected: Should add new import statement at the top + +console.log("Hello World"); +someFunction(); diff --git a/codemods/v4/tests/import-utils/case-2-no-action-exists/expected.ts b/codemods/v4/tests/import-utils/case-2-no-action-exists/expected.ts new file mode 100644 index 0000000..bd889e7 --- /dev/null +++ b/codemods/v4/tests/import-utils/case-2-no-action-exists/expected.ts @@ -0,0 +1,7 @@ +// CASE 2: NO ACTION - All imports already exist exactly as requested +// Expected: Should do nothing, return unchanged + +import { resolve, join } from "node:path"; + +console.log("Hello World"); +someFunction(); diff --git a/codemods/v4/tests/import-utils/case-2-no-action-exists/input.ts b/codemods/v4/tests/import-utils/case-2-no-action-exists/input.ts new file mode 100644 index 0000000..bd889e7 --- /dev/null +++ b/codemods/v4/tests/import-utils/case-2-no-action-exists/input.ts @@ -0,0 +1,7 @@ +// CASE 2: NO ACTION - All imports already exist exactly as requested +// Expected: Should do nothing, return unchanged + +import { resolve, join } from "node:path"; + +console.log("Hello World"); +someFunction(); diff --git a/codemods/v4/tests/import-utils/case-3-replace-partial/expected.ts b/codemods/v4/tests/import-utils/case-3-replace-partial/expected.ts new file mode 100644 index 0000000..2d167ed --- /dev/null +++ b/codemods/v4/tests/import-utils/case-3-replace-partial/expected.ts @@ -0,0 +1,7 @@ +// CASE 3: REPLACE - Import statement exists but needs editing +// Expected: Should replace existing import with new one containing both resolve and join + +import { resolve, join } from "node:path"; + +console.log("Hello World"); +someFunction(); diff --git a/codemods/v4/tests/import-utils/case-3-replace-partial/input.ts b/codemods/v4/tests/import-utils/case-3-replace-partial/input.ts new file mode 100644 index 0000000..96dbd42 --- /dev/null +++ b/codemods/v4/tests/import-utils/case-3-replace-partial/input.ts @@ -0,0 +1,7 @@ +// CASE 3: REPLACE - Import statement exists but needs editing +// Expected: Should replace existing import with new one containing both resolve and join + +import { resolve} from "node:path"; + +console.log("Hello World"); +someFunction(); diff --git a/codemods/v4/tests/import-utils/case-4-quote-preservation/expected.ts b/codemods/v4/tests/import-utils/case-4-quote-preservation/expected.ts new file mode 100644 index 0000000..a1e1faa --- /dev/null +++ b/codemods/v4/tests/import-utils/case-4-quote-preservation/expected.ts @@ -0,0 +1,7 @@ +// CASE 4: REPLACE with quote preservation +// Expected: Should preserve single quotes when replacing import + +import { resolve, join } from 'node:path'; + +console.log("Hello World"); +someFunction(); diff --git a/codemods/v4/tests/import-utils/case-4-quote-preservation/input.ts b/codemods/v4/tests/import-utils/case-4-quote-preservation/input.ts new file mode 100644 index 0000000..ab7490a --- /dev/null +++ b/codemods/v4/tests/import-utils/case-4-quote-preservation/input.ts @@ -0,0 +1,7 @@ +// CASE 4: REPLACE with quote preservation +// Expected: Should preserve single quotes when replacing import + +import { resolve} from 'node:path'; + +console.log("Hello World"); +someFunction(); diff --git a/codemods/v4/tests/import-utils/case-5-add-after-existing/expected.ts b/codemods/v4/tests/import-utils/case-5-add-after-existing/expected.ts new file mode 100644 index 0000000..e37770a --- /dev/null +++ b/codemods/v4/tests/import-utils/case-5-add-after-existing/expected.ts @@ -0,0 +1,9 @@ +// CASE 5: ADD after existing imports from different sources +// Expected: Should add new import after existing imports + +import { readFile } from "node:fs"; +import { EventEmitter } from "node:events"; +import { resolve, join } from "node:path"; + +console.log("Hello World"); +someFunction(); diff --git a/codemods/v4/tests/import-utils/case-5-add-after-existing/input.ts b/codemods/v4/tests/import-utils/case-5-add-after-existing/input.ts new file mode 100644 index 0000000..7bc4755 --- /dev/null +++ b/codemods/v4/tests/import-utils/case-5-add-after-existing/input.ts @@ -0,0 +1,8 @@ +// CASE 5: ADD after existing imports from different sources +// Expected: Should add new import after existing imports + +import { readFile } from "node:fs"; +import { EventEmitter } from "node:events"; + +console.log("Hello World"); +someFunction(); diff --git a/codemods/v4/tests/import-utils/case-6-mixed-type-to-runtime/expected.ts b/codemods/v4/tests/import-utils/case-6-mixed-type-to-runtime/expected.ts new file mode 100644 index 0000000..815cff4 --- /dev/null +++ b/codemods/v4/tests/import-utils/case-6-mixed-type-to-runtime/expected.ts @@ -0,0 +1,7 @@ +// CASE 6: REPLACE - Type import exists, replace with runtime import +// Expected: Should replace type import with runtime import + +import { resolve, join } from "node:path"; + +console.log("Hello World"); +someFunction(); diff --git a/codemods/v4/tests/import-utils/case-6-mixed-type-to-runtime/input.ts b/codemods/v4/tests/import-utils/case-6-mixed-type-to-runtime/input.ts new file mode 100644 index 0000000..aaa4f46 --- /dev/null +++ b/codemods/v4/tests/import-utils/case-6-mixed-type-to-runtime/input.ts @@ -0,0 +1,7 @@ +// CASE 6: REPLACE - Type import exists, replace with runtime import +// Expected: Should replace type import with runtime import + +import type { resolve, join } from "node:path"; + +console.log("Hello World"); +someFunction(); diff --git a/codemods/v4/tests/import-utils/case-6-type-to-runtime-test.ts b/codemods/v4/tests/import-utils/case-6-type-to-runtime-test.ts new file mode 100644 index 0000000..265dbc9 --- /dev/null +++ b/codemods/v4/tests/import-utils/case-6-type-to-runtime-test.ts @@ -0,0 +1,25 @@ +import type { SgRoot } from "codemod:ast-grep"; +import type TSX from "codemod:ast-grep/langs/tsx"; +import { ensureImport } from "../../utils/imports.ts"; + +/** + * CASE 6: REPLACE - Type import exists, replace with runtime import + * This tests replacing an existing type import with a runtime import + */ +async function transform(root: SgRoot): Promise { + const rootNode = root.root(); + + // Replace type import with runtime import + const importResult = ensureImport(rootNode as any, "node:path", [ + { type: "named", name: "resolve", typed: false }, + { type: "named", name: "join", typed: false }, + ]); + + if (importResult.edit.insertedText && importResult.edit.insertedText.trim()) { + return rootNode.commitEdits([importResult.edit]); + } + + return rootNode.text(); +} + +export default transform; diff --git a/codemods/v4/tests/import-utils/case-7-mixed-runtime-to-type/expected.ts b/codemods/v4/tests/import-utils/case-7-mixed-runtime-to-type/expected.ts new file mode 100644 index 0000000..415f69d --- /dev/null +++ b/codemods/v4/tests/import-utils/case-7-mixed-runtime-to-type/expected.ts @@ -0,0 +1,7 @@ +// CASE 7: REPLACE - Runtime import exists, replace with type import +// Expected: Should replace runtime import with type import + +import type { PathType, ResolveType } from "node:path"; + +console.log("Hello World"); +someFunction(); diff --git a/codemods/v4/tests/import-utils/case-7-mixed-runtime-to-type/input.ts b/codemods/v4/tests/import-utils/case-7-mixed-runtime-to-type/input.ts new file mode 100644 index 0000000..61f7d8b --- /dev/null +++ b/codemods/v4/tests/import-utils/case-7-mixed-runtime-to-type/input.ts @@ -0,0 +1,7 @@ +// CASE 7: REPLACE - Runtime import exists, replace with type import +// Expected: Should replace runtime import with type import + +import { resolve, join } from "node:path"; + +console.log("Hello World"); +someFunction(); diff --git a/codemods/v4/tests/import-utils/case-7-runtime-to-type-test.ts b/codemods/v4/tests/import-utils/case-7-runtime-to-type-test.ts new file mode 100644 index 0000000..edb36b9 --- /dev/null +++ b/codemods/v4/tests/import-utils/case-7-runtime-to-type-test.ts @@ -0,0 +1,25 @@ +import type { SgRoot } from "codemod:ast-grep"; +import type TSX from "codemod:ast-grep/langs/tsx"; +import { ensureImport } from "../../utils/imports.ts"; + +/** + * CASE 7: REPLACE - Runtime import exists, replace with type import + * This tests replacing an existing runtime import with a type import + */ +async function transform(root: SgRoot): Promise { + const rootNode = root.root(); + + // Replace runtime import with type import + const importResult = ensureImport(rootNode as any, "node:path", [ + { type: "named", name: "PathType", typed: true }, + { type: "named", name: "ResolveType", typed: true }, + ]); + + if (importResult.edit.insertedText && importResult.edit.insertedText.trim()) { + return rootNode.commitEdits([importResult.edit]); + } + + return rootNode.text(); +} + +export default transform; diff --git a/codemods/v4/tests/import-utils/case-8-complex-aliases/expected.ts b/codemods/v4/tests/import-utils/case-8-complex-aliases/expected.ts new file mode 100644 index 0000000..6cf68c5 --- /dev/null +++ b/codemods/v4/tests/import-utils/case-8-complex-aliases/expected.ts @@ -0,0 +1,7 @@ +// CASE 8: Complex imports with aliases and default +// Expected: Should handle aliases and default imports correctly + +import { resolve, join } from "node:path"; + +console.log("Hello World"); +someFunction(); diff --git a/codemods/v4/tests/import-utils/case-8-complex-aliases/input.ts b/codemods/v4/tests/import-utils/case-8-complex-aliases/input.ts new file mode 100644 index 0000000..2b698a2 --- /dev/null +++ b/codemods/v4/tests/import-utils/case-8-complex-aliases/input.ts @@ -0,0 +1,7 @@ +// CASE 8: Complex imports with aliases and default +// Expected: Should handle aliases and default imports correctly + +import Base, { A, B, C as See } from "node:path"; + +console.log("Hello World"); +someFunction(); diff --git a/codemods/v4/tests/import-utils/case-9-mixed-complex/expected.ts b/codemods/v4/tests/import-utils/case-9-mixed-complex/expected.ts new file mode 100644 index 0000000..948d210 --- /dev/null +++ b/codemods/v4/tests/import-utils/case-9-mixed-complex/expected.ts @@ -0,0 +1,9 @@ +// CASE 9: Mixed scenario - existing partial + type imports +// Expected: Should handle both type and runtime imports in same file + +import type { PathType } from "node:path"; +import { resolve, join } from "node:path"; +import { readFile } from "node:fs"; + +console.log("Hello World"); +someFunction(); diff --git a/codemods/v4/tests/import-utils/case-9-mixed-complex/input.ts b/codemods/v4/tests/import-utils/case-9-mixed-complex/input.ts new file mode 100644 index 0000000..09b5a85 --- /dev/null +++ b/codemods/v4/tests/import-utils/case-9-mixed-complex/input.ts @@ -0,0 +1,8 @@ +// CASE 9: Mixed scenario - existing partial + type imports +// Expected: Should handle both type and runtime imports in same file + +import { resolve, join } from "node:path"; +import { readFile } from "node:fs"; + +console.log("Hello World"); +someFunction(); diff --git a/codemods/v4/tests/import-utils/test-runner.ts b/codemods/v4/tests/import-utils/test-runner.ts new file mode 100644 index 0000000..788e2df --- /dev/null +++ b/codemods/v4/tests/import-utils/test-runner.ts @@ -0,0 +1,28 @@ +import type { SgRoot } from "codemod:ast-grep"; +import type TSX from "codemod:ast-grep/langs/tsx"; +import { ensureImport } from "../../utils/imports.ts"; + +//command to run the test: +//codemod jssg run --language typescript --target case-1-add-empty-file/input.ts test-runner.ts + +/** + * Test runner for import-utils functionality + * This tests the core ensureImport function with various scenarios + */ +async function transform(root: SgRoot): Promise { + const rootNode = root.root(); + + // Test: Add resolve and join imports from node:path + const importResult = ensureImport(rootNode as any, "node:path", [ + { type: "named", name: "resolve", typed: false }, + { type: "named", name: "join", typed: false }, + ]); + + if (importResult.edit.insertedText && importResult.edit.insertedText.trim()) { + return rootNode.commitEdits([importResult.edit]); + } + + return rootNode.text(); +} + +export default transform; diff --git a/codemods/v4/tests/shallow-function-reactivity/expected.ts b/codemods/v4/tests/shallow-function-reactivity/expected.ts new file mode 100644 index 0000000..8ebeff7 --- /dev/null +++ b/codemods/v4/tests/shallow-function-reactivity/expected.ts @@ -0,0 +1,13 @@ +// Test case: useLazyAsyncData with single function argument +const { data: users } = useLazyAsyncData(() => $fetch("/api/users"), { deep: true }); + +// Test case: useAsyncData with object options already +const { data: posts } = useAsyncData("posts", () => $fetch("/api/posts"), { + server: false, +}); + +// Test case: useFetch with just function +const { data: comments } = useFetch(() => $fetch("/api/comments"), { deep: true }); + +// Test case: useLazyFetch with key and function +const { data: likes } = useLazyFetch("likes", () => $fetch("/api/likes")); diff --git a/codemods/v4/tests/shallow-function-reactivity/input.ts b/codemods/v4/tests/shallow-function-reactivity/input.ts new file mode 100644 index 0000000..74f2166 --- /dev/null +++ b/codemods/v4/tests/shallow-function-reactivity/input.ts @@ -0,0 +1,13 @@ +// Test case: useLazyAsyncData with single function argument +const { data: users } = useLazyAsyncData(() => $fetch("/api/users")); + +// Test case: useAsyncData with object options already +const { data: posts } = useAsyncData("posts", () => $fetch("/api/posts"), { + server: false, +}); + +// Test case: useFetch with just function +const { data: comments } = useFetch(() => $fetch("/api/comments")); + +// Test case: useLazyFetch with key and function +const { data: likes } = useLazyFetch("likes", () => $fetch("/api/likes")); diff --git a/codemods/v4/tests/template-compilation-changes/expected.ts b/codemods/v4/tests/template-compilation-changes/expected.ts new file mode 100644 index 0000000..32f7052 --- /dev/null +++ b/codemods/v4/tests/template-compilation-changes/expected.ts @@ -0,0 +1,125 @@ +// Test case 1: Basic addTemplate with .ejs file +addTemplate({ + fileName: "appinsights-vue.js", + options: { + /* some options */ + }, + getContents({ options }) { + const contents = readFileSync( + resolver.resolve("./runtime/plugin.ejs"), + "utf-8" + ); + + return template(contents)({ + options, + }); + }, +}); + +// Test case 2: addTemplate with multiple properties +addTemplate({ + fileName: "test.js", + mode: "client", + options: { + key: "value", + }, + getContents({ options }) { + const contents = readFileSync( + resolver.resolve("./templates/test.ejs"), + "utf-8" + ); + + return template(contents)({ + options, + }); + }, + write: true, +}); + +// Test case 3: addTemplate with non-.ejs file (should not be transformed) +addTemplate({ + fileName: "normal.js", + options: {}, + src: resolver.resolve("./runtime/plugin.ts"), +}); + +// Test case 4: addTemplate with .ejs file and existing imports +import { readFileSync } from "node:fs"; + +addTemplate({ + fileName: "existing-import.js", + getContents({ options }) { + const contents = readFileSync( + resolver.resolve("./runtime/existing.ejs"), + "utf-8" + ); + + return template(contents)({ + options, + }); + }, +}); + +// Test case 5: addTemplate with .ejs file and existing lodash import +import { template } from "lodash-es"; + +addTemplate({ + fileName: "lodash-import.js", + getContents({ options }) { + const contents = readFileSync( + resolver.resolve("./runtime/lodash.ejs"), + "utf-8" + ); + + return template(contents)({ + options, + }); + }, +}); + +// Test case 6: Multiple addTemplate calls with .ejs files +addTemplate({ + fileName: "first.js", + getContents({ options }) { + const contents = readFileSync( + resolver.resolve("./templates/first.ejs"), + "utf-8" + ); + + return template(contents)({ + options, + }); + }, +}); + +addTemplate({ + fileName: "second.js", + getContents({ options }) { + const contents = readFileSync( + resolver.resolve("./templates/second.ejs"), + "utf-8" + ); + + return template(contents)({ + options, + }); + }, +}); + +// Test case 7: addTemplate with both imports already present +import { readFileSync } from "node:fs"; +import { template } from "lodash-es"; + +addTemplate({ + fileName: "both-imports.js", + getContents({ options }) { + const contents = readFileSync( + resolver.resolve("./runtime/both.ejs"), + "utf-8" + ); + + return template(contents)({ + options, + }); + }, +}); diff --git a/codemods/v4/tests/template-compilation-changes/input.ts b/codemods/v4/tests/template-compilation-changes/input.ts new file mode 100644 index 0000000..838166e --- /dev/null +++ b/codemods/v4/tests/template-compilation-changes/input.ts @@ -0,0 +1,62 @@ +// Test case 1: Basic addTemplate with .ejs file +addTemplate({ + fileName: "appinsights-vue.js", + options: { + /* some options */ + }, + src: resolver.resolve("./runtime/plugin.ejs"), +}); + +// Test case 2: addTemplate with multiple properties +addTemplate({ + fileName: "test.js", + mode: "client", + options: { + key: "value", + }, + src: resolver.resolve("./templates/test.ejs"), + write: true, +}); + +// Test case 3: addTemplate with non-.ejs file (should not be transformed) +addTemplate({ + fileName: "normal.js", + options: {}, + src: resolver.resolve("./runtime/plugin.ts"), +}); + +// Test case 4: addTemplate with .ejs file and existing imports +import { readFileSync } from "node:fs"; + +addTemplate({ + fileName: "existing-import.js", + src: resolver.resolve("./runtime/existing.ejs"), +}); + +// Test case 5: addTemplate with .ejs file and existing lodash import +import { template } from "lodash-es"; + +addTemplate({ + fileName: "lodash-import.js", + src: resolver.resolve("./runtime/lodash.ejs"), +}); + +// Test case 6: Multiple addTemplate calls with .ejs files +addTemplate({ + fileName: "first.js", + src: resolver.resolve("./templates/first.ejs"), +}); + +addTemplate({ + fileName: "second.js", + src: resolver.resolve("./templates/second.ejs"), +}); + +// Test case 7: addTemplate with both imports already present +import { readFileSync } from "node:fs"; +import { template } from "lodash-es"; + +addTemplate({ + fileName: "both-imports.js", + src: resolver.resolve("./runtime/both.ejs"), +}); diff --git a/codemods/v4/default-data-error-value/tsconfig.json b/codemods/v4/tsconfig.json similarity index 74% rename from codemods/v4/default-data-error-value/tsconfig.json rename to codemods/v4/tsconfig.json index 469fc5a..a20bc70 100644 --- a/codemods/v4/default-data-error-value/tsconfig.json +++ b/codemods/v4/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "module": "NodeNext", "moduleResolution": "NodeNext", - "types": ["@codemod.com/jssg-types"], + "types": ["@codemod.com/jssg-types", "node"], "allowImportingTsExtensions": true, "noEmit": true, "verbatimModuleSyntax": true, @@ -11,7 +11,7 @@ "strictNullChecks": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, - "noUncheckedIndexedAccess": true - }, - "exclude": ["tests"] + "noUncheckedIndexedAccess": true, + "skipLibCheck": true + } } diff --git a/codemods/v4/utils/ast-utils.ts b/codemods/v4/utils/ast-utils.ts new file mode 100644 index 0000000..ea320f9 --- /dev/null +++ b/codemods/v4/utils/ast-utils.ts @@ -0,0 +1,91 @@ +import type { SgRoot, SgNode, Edit, TypesMap } from "codemod:ast-grep"; + +/** + * Core AST utilities for codemods + */ + +/** + * Common Nuxt data fetching hooks + */ +export const DATA_FETCH_HOOKS = [ + "useAsyncData", + "useFetch", + "useLazyAsyncData", + "useLazyFetch", +] as const; + +/** + * Quick check if file contains specific content before processing + */ +export function hasContent( + root: SgRoot, + searchText: string +): boolean { + return root.root().text().includes(searchText); +} + +/** + * Check if file contains any of the specified content + */ +export function hasAnyContent( + root: SgRoot, + searchTexts: readonly string[] +): boolean { + const text = root.root().text(); + return searchTexts.some((searchText) => text.includes(searchText)); +} + +/** + * Apply edits and return result, or null if no changes + */ +export function applyEdits( + rootNode: SgNode, + edits: Edit[] +): string | null { + if (edits.length === 0) { + return null; + } + return rootNode.commitEdits(edits); +} + +/** + * Find function calls with specific first argument (handles quote variations) + */ +export function findFunctionCallsWithFirstArg( + rootNode: SgNode, + functionName: string, + firstArg: string +): SgNode[] { + const results: SgNode[] = []; + + // Handle both quote styles + const patterns = [ + `${functionName}('${firstArg}', $CALLBACK)`, + `${functionName}("${firstArg}", $CALLBACK)`, + ]; + + for (const pattern of patterns) { + const calls = rootNode.findAll({ + rule: { pattern }, + }); + results.push(...calls); + } + + return results; +} + +/** + * Replace text in node using regex - returns edit or null + */ +export function replaceInNode( + node: SgNode, + searchRegex: RegExp, + replacement: string +): Edit | null { + const text = node.text(); + if (searchRegex.test(text)) { + const newText = text.replace(searchRegex, replacement); + return node.replace(newText); + } + return null; +} diff --git a/codemods/v4/utils/imports.ts b/codemods/v4/utils/imports.ts new file mode 100644 index 0000000..458099a --- /dev/null +++ b/codemods/v4/utils/imports.ts @@ -0,0 +1,536 @@ +import type { SgNode, Edit, TypesMap } from "codemod:ast-grep"; +import type tsxTypes from "codemod:ast-grep/langs/tsx"; +import type tsTypes from "codemod:ast-grep/langs/typescript"; + +// <------------ IMPORT TYPES ------------> + +type NamedImportSpecifier = { + name: string; + type: "named"; + typed: boolean; //a flag to track whether an import is a type-only import (e.g. import type { named } from ...) or runtime import (e.g. import { named } from ...) + //typed import is for type checking during compile time, NOT runtime in ts. + alias?: string; +}; + +type DefaultImportSpecifier = { + type: "default"; + name: string; // Default imports DO have names! e.g., "React" in "import React from 'react'" + typed: boolean; +}; + +type ImportSpecifier = NamedImportSpecifier | DefaultImportSpecifier; + +// <------------ HELPERS ------------> + +function detectTypeOnlyImport(importNode: SgNode): boolean { + // Find the import_clause + const importClause = + importNode.field("import_clause") || + importNode.find({ + rule: { kind: "import_clause" }, + }); + + if (!importClause) { + return false; + } + + // Check if there's a 'type' token before the import_clause + // This indicates "import type { ... }" pattern + const children = importNode.children(); + let foundImport = false; + + for (const child of children) { + if (child.kind() === "import") { + foundImport = true; + continue; + } + + if (foundImport && child.kind() === "type") { + // Found 'type' token right after 'import' - this is a type-only import + return true; + } + + if (foundImport && child.kind() === "import_clause") { + // Found import_clause without 'type' in between - this is a regular import + return false; + } + } + + return false; +} + +function findImportFromSource( + program: SgNode, //root ast node of entire ts/tsx file + source: string //the string we're looking for in the end of the import statement. +): SgNode | null { + //it will either return the ast node or null. + + const allImports = program.findAll({ + rule: { + kind: "import_statement", + }, + }); //"give me every node that is import_statement". aka the entire import type { Something } from "./types" and other types of imports + //so now allImports is an array of all the import_statement NODES. each item is an importNode. + + for (const importNode of allImports) { + //look through each importNode + const sourceNode = importNode.field("source"); //get the source field of each node. source is the part after "from" + if (sourceNode) { + //if the sourceNode exists. else ignore it (rare). + + //need to find the string_fragment inside the sourceNode becasue in ast grep's + // ts/tsx syntax, the string without the quotes is actually a nested node. + // so: source: (string) aka "react" -> string_fragment aka react (w/o quotes) + const stringFragment = sourceNode.find({ + rule: { + kind: "string_fragment", + }, + }); + + if (stringFragment) { + // if strgin fragment exists, keep track of the str without the quotes + const fragmentText = stringFragment.text(); + + if (fragmentText === source) { + //compare found string with the source arg passed in + return importNode; + } + } + } + } + return null; +} + +function getExistingSpecifiers( + importNode: SgNode //takes import nodes as arg +): ImportSpecifier[] { + //returns a list of import specifiers + const importSpecifiers: ImportSpecifier[] = []; //initialize empty array to store import specifiers + + //records whether the import is type-only import + const isTypeImport = detectTypeOnlyImport(importNode); + + // Try field first, then fallback to finding by kind + let importClause = importNode.field("import_clause"); + if (!importClause) { + // Fallback: find import_clause as a child node + importClause = importNode.find({ + rule: { kind: "import_clause" }, + }); + } + if (!importClause) { + return importSpecifiers; + } //need importClause node to look inside it for specifiers + + // Find default import - first identifier that's NOT inside named_imports + const defaultImport = importClause.find({ + //finding the default aka the first identifier not inside {} + rule: { + kind: "identifier", + not: { + inside: { + kind: "named_imports", + }, + }, + }, + }); + + if (defaultImport) { + //if default exists, add a default to imporSpecifiers list we defined in the beginnig + importSpecifiers.push({ + type: "default", + name: defaultImport.text(), + typed: isTypeImport, + }); + } + + // Find named imports + let namedImports = importClause.field("named_imports"); + if (!namedImports) { + // Fallback: find named_imports as a child node + namedImports = importClause.find({ + rule: { kind: "named_imports" }, + }); + } + if (namedImports) { + const specifiers = namedImports.findAll({ + rule: { kind: "import_specifier" }, + }); + + for (const spec of specifiers) { + const nameNode = spec.field("name"); + const aliasNode = spec.field("alias"); + + if (nameNode) { + const name = nameNode.text(); + const alias = aliasNode ? aliasNode.text() : undefined; + + importSpecifiers.push({ + type: "named", + name: name, + typed: isTypeImport, + alias: alias, + }); + } + } + } + + return importSpecifiers; +} + +function getInsertionPoint( + program: SgNode +): number { + const allImports = program.findAll({ + rule: { + kind: "import_statement", + inside: { + kind: "program", + stopBy: "end", + }, + }, + }); + + if (allImports.length === 0) { + const hashBang = program.find({ + rule: { kind: "hash_bang_line" }, + }); + + if (hashBang) { + return hashBang.range().end.index; + } + + return 0; + } + + const lastImport = allImports[allImports.length - 1]; + if (lastImport) { + return lastImport.range().end.index; + } + + return 0; +} + +function detectQuoteStyle( + program: SgNode, + preferredSource?: string +): "'" | '"' { + // First, try to find an import from the specific source we're working with + if (preferredSource) { + const targetImport = findImportFromSource(program, preferredSource); + if (targetImport) { + const sourceNode = targetImport.field("source"); + if (sourceNode) { + const fullText = sourceNode.text(); + if (fullText.startsWith("'")) { + return "'"; + } + if (fullText.startsWith('"')) { + return '"'; + } + } + } + } + + // Fall back to any import statement + const anyImport = program.find({ + rule: { + kind: "import_statement", + has: { + field: "source", + kind: "string", + }, + }, + }); + + if (anyImport) { + const sourceNode = anyImport.field("source"); + if (sourceNode) { + const fullText = sourceNode.text(); + if (fullText.startsWith("'")) { + return "'"; + } + if (fullText.startsWith('"')) { + return '"'; + } + } + } + + return '"'; // Default to double quotes +} + +function buildImportStatement( + source: string, + importSpecifiers: ImportSpecifier[], + quoteStyle: "'" | '"' = '"' +): string { + // Check if we have mixed types - this should not happen with the new logic + const hasTyped = importSpecifiers.some((spec) => spec.typed); + const hasRuntime = importSpecifiers.some((spec) => !spec.typed); + + if (hasTyped && hasRuntime) { + throw new Error( + "buildImportStatement should not receive mixed typed/runtime imports" + ); + } + + // All imports are the same type + const isTypeImport = importSpecifiers.every((spec) => spec.typed); + return buildSingleImportStatement( + source, + importSpecifiers, + quoteStyle, + isTypeImport + ); +} + +function buildSingleImportStatement( + source: string, + importSpecifiers: ImportSpecifier[], + quoteStyle: "'" | '"' = '"', + isTypeImport: boolean = false +): string { + const defaultSpecs = importSpecifiers.filter( + (spec) => spec.type === "default" + ); + const namedSpecs = importSpecifiers.filter( + (spec) => spec.type === "named" + ) as NamedImportSpecifier[]; + + const importKeyword = isTypeImport ? "import type" : "import"; + + const parts: string[] = []; + + if (defaultSpecs.length > 0 && defaultSpecs[0]) { + parts.push(defaultSpecs[0].name); + } + + if (namedSpecs.length > 0) { + const nameParts = namedSpecs.map((spec) => { + return spec.alias ? `${spec.name} as ${spec.alias}` : spec.name; + }); + parts.push(`{ ${nameParts.join(", ")} }`); + } + + const result = `${importKeyword} ${parts.join( + ", " + )} from ${quoteStyle}${source}${quoteStyle};`; + + return result; +} + +// <------------ MAIN FUNCTION ------------> +export function ensureImport( + program: SgNode, + source: string, + imports: ImportSpecifier[] +): { + edit: Edit; + importAliases: string[]; +} { + // Step 1: Find existing import from source + const existingImport = findImportFromSource(program, source); + + // Step 2: Parse existing specifiers + const existingSpecs = existingImport + ? getExistingSpecifiers(existingImport) + : []; + + // Step 3: Calculate what names will be available (aliases) + const importAliases: string[] = []; + + for (const requestedSpec of imports) { + if (requestedSpec.type === "default") { + // Check if default already exists + const existingDefault = existingSpecs.find( + (spec) => spec.type === "default" + ); + if (existingDefault && existingDefault.type === "default") { + importAliases.push(existingDefault.name); + } else { + importAliases.push(requestedSpec.name); + } + } else if (requestedSpec.type === "named") { + // Check if named import already exists + const existingNamed = existingSpecs.find( + (spec) => spec.type === "named" && spec.name === requestedSpec.name + ); + if (existingNamed && existingNamed.type === "named") { + importAliases.push(existingNamed.alias || existingNamed.name); + } else { + importAliases.push(requestedSpec.alias || requestedSpec.name); + } + } + } + + // Step 4: Detect quote style and get insertion point + const quoteStyle = detectQuoteStyle(program, source); + const insertionPoint = getInsertionPoint(program); + + // Step 5: Check if ALL requested imports already exist exactly as requested + const allImportsExist = imports.every((requestedSpec) => { + return existingSpecs.some((existing) => { + if (requestedSpec.type === "default" && existing.type === "default") { + return existing.typed === requestedSpec.typed; + } + if (requestedSpec.type === "named" && existing.type === "named") { + return ( + existing.name === requestedSpec.name && + existing.typed === requestedSpec.typed + ); + } + return false; + }); + }); + + // CASE 1: If imports already exist → Don't do anything, return empty edit + if (allImportsExist) { + return { + edit: { startPos: 0, endPos: 0, insertedText: "" }, + importAliases: imports.map((spec) => { + const existing = existingSpecs.find( + (existing) => + (spec.type === "default" && + existing.type === "default" && + existing.typed === spec.typed) || + (spec.type === "named" && + existing.type === "named" && + existing.name === spec.name && + existing.typed === spec.typed) + ); + return ( + (existing?.type === "named" ? existing.alias : undefined) || + existing?.name || + spec.name + ); + }), + }; + } + + //fallback logic for when no existing import from source exists + if (!existingImport) { + const newImportText = buildImportStatement(source, imports, quoteStyle); + const allImports = program.findAll({ + rule: { + kind: "import_statement", + inside: { + kind: "program", + stopBy: "end", + }, + }, + }); + + if (allImports.length === 0) { + // No imports at all - add at beginning with proper positioning + const hashBang = program.find({ + rule: { kind: "hash_bang_line" }, + }); + + const insertPos = hashBang ? hashBang.range().end.index : 0; + + return { + edit: { + startPos: insertPos, + endPos: insertPos, + insertedText: (hashBang ? "\n" : "") + newImportText + "\n", + }, + importAliases: imports.map((spec) => spec.alias || spec.name), + }; + } else { + // Add after last import with proper positioning + const lastImport = allImports[allImports.length - 1]; + return { + edit: { + startPos: lastImport.range().end.index, + endPos: lastImport.range().end.index, + insertedText: "\n" + newImportText, + }, + importAliases: imports.map((spec) => spec.alias || spec.name), + }; + } + } + + // Step 6: Determine if we need to REPLACE or ADD + let edit: Edit; + + if (existingImport) { + // Check if we have mixed type/runtime scenario + const existingSpecs = getExistingSpecifiers(existingImport); + const hasExistingTyped = existingSpecs.some((spec) => spec.typed); + const hasExistingRuntime = existingSpecs.some((spec) => !spec.typed); + const hasRequestedTyped = imports.some((spec) => spec.typed); + const hasRequestedRuntime = imports.some((spec) => !spec.typed); + + // Check if this is truly a mixed scenario (different imports) or just type conversion (same imports) + const isMixedScenario = + (hasExistingTyped && hasRequestedRuntime) || + (hasExistingRuntime && hasRequestedTyped); + + if (isMixedScenario) { + // Check if requested imports are actually NEW imports (not just type conversions) + const hasNewImports = imports.some((requestedSpec) => { + return !existingSpecs.some((existing) => { + if (requestedSpec.type === "default" && existing.type === "default") { + return true; // Same default import + } + if (requestedSpec.type === "named" && existing.type === "named") { + return existing.name === requestedSpec.name; // Same named import + } + return false; + }); + }); + + if (hasNewImports) { + // CASE 2A: True mixed scenario → ADD new separate import statement (don't replace) + const newImportText = buildImportStatement(source, imports, quoteStyle); + + // For mixed scenarios, always add the new import AFTER the existing one + const existingImportEnd = existingImport.range().end.index; + edit = { + startPos: existingImportEnd, + endPos: existingImportEnd, + insertedText: "\n" + newImportText, + }; + } else { + // CASE 2B: Type conversion scenario → REPLACE the existing statement + const newImportText = buildImportStatement(source, imports, quoteStyle); + edit = existingImport.replace(newImportText); + } + } else { + // CASE 2C: Same type scenario → REPLACE the existing statement + const newImportText = buildImportStatement(source, imports, quoteStyle); + edit = existingImport.replace(newImportText); + } + } else { + // CASE 3: Import statement doesn't exist → ADD new import statement + const newImportText = buildImportStatement(source, imports, quoteStyle); + + // Check if we're inserting after existing imports + const hasExistingImports = + program.findAll({ + rule: { kind: "import_statement" }, + }).length > 0; + + edit = { + startPos: insertionPoint, + endPos: insertionPoint, + insertedText: hasExistingImports + ? "\n" + newImportText + : newImportText + "\n", + }; + } + + // Calculate import aliases - just use the requested names since we're replacing + const finalImportAliases: string[] = imports.map((spec) => { + if (spec.type === "default") { + return spec.name; + } else { + return spec.alias || spec.name; + } + }); + + return { + edit, + importAliases: finalImportAliases, + }; +} diff --git a/codemods/v4/utils/index.ts b/codemods/v4/utils/index.ts new file mode 100644 index 0000000..8cfa017 --- /dev/null +++ b/codemods/v4/utils/index.ts @@ -0,0 +1,14 @@ +/** + * Nuxt v4 Codemod Utilities + * + * Centralized utilities for all Nuxt v4 codemods + */ + +// Core AST utilities +export * from "./ast-utils.ts"; + +// Import management +export * from "./imports.ts"; + +// Re-export commonly used types +export type { SgRoot, SgNode, Edit } from "codemod:ast-grep"; diff --git a/codemods/v4/workflow.yaml b/codemods/v4/workflow.yaml new file mode 100644 index 0000000..0653e5f --- /dev/null +++ b/codemods/v4/workflow.yaml @@ -0,0 +1,89 @@ +version: "1" + +nodes: + - id: apply-transforms-typescript + name: Apply AST Transformations for nuxt upgrade (TypeScript files) + type: automatic + language: "typescript" + include: + - "**/*.ts" + - "**/*.tsx" + steps: + - name: "Apply absolute watch path transformations" + js-ast-grep: + js_file: scripts/absolute-watch-path.ts + exclude: + - "**/*.cjs" + - "**/*.md" + - "**/*.txt" + - name: "Apply default data error value transformations" + js-ast-grep: + js_file: scripts/default-data-error-value.ts + exclude: + - "**/*.cjs" + - "**/*.md" + - "**/*.txt" + - name: "Apply deprecated dedupe value transformations" + js-ast-grep: + js_file: scripts/deprecated-dedupe-value.ts + exclude: + - "**/*.cjs" + - "**/*.md" + - "**/*.txt" + - name: "Apply template compilation changes transformations" + js-ast-grep: + js_file: scripts/template-compilation-changes.ts + exclude: + - "**/*.cjs" + - "**/*.md" + - "**/*.txt" + - name: "Apply shallow function reactivity transformations" + js-ast-grep: + js_file: scripts/shallow-function-reactivity.ts + exclude: + - "**/*.cjs" + - "**/*.md" + - "**/*.txt" + + - id: apply-transforms-vue + name: Apply AST Transformations for nuxt upgrade (Vue files) + type: automatic + language: "html" + include: + - "**/*.vue" + steps: + - name: "Apply absolute watch path transformations" + js-ast-grep: + js_file: scripts/absolute-watch-path.ts + exclude: + - "**/*.cjs" + - "**/*.md" + - "**/*.txt" + - name: "Apply default data error value transformations" + js-ast-grep: + js_file: scripts/default-data-error-value.ts + exclude: + - "**/*.cjs" + - "**/*.md" + - "**/*.txt" + - name: "Apply deprecated dedupe value transformations" + js-ast-grep: + js_file: scripts/deprecated-dedupe-value.ts + exclude: + - "**/*.cjs" + - "**/*.md" + - "**/*.txt" + - name: "Apply template compilation changes transformations" + js-ast-grep: + js_file: scripts/template-compilation-changes.ts + exclude: + - "**/*.cjs" + - "**/*.md" + - "**/*.txt" + - name: "Apply shallow function reactivity transformations" + js-ast-grep: + js_file: scripts/shallow-function-reactivity.ts + exclude: + - "**/*.cjs" + - "**/*.md" + - "**/*.txt"