Repository: vercel/next.js
Pull request: [ts-next-plugin] auto import metadata type

Follow up of https://github.com/vercel/next.js/pull/78237

Auto import the `Metadata` type when completing the `metadata` and `generateMetadata` option.

- If there is a `'use ...'` top directive, add import below the directive.
- If there is an existing Metadata import, do not add another import.
  - This includes aliases as well.

https://github.com/user-attachments/assets/5ea1ce7e-2320-41f0-ba33-af7568bbdd11

Changed files:
filename: packages/next/src/server/typescript/index.ts
patch: @@ -118,7 +118,8 @@ export const createTSPlugin: tsModule.server.PluginModuleFactory = ({
     ) => {
       const entryCompletionEntryDetails = entryConfig.getCompletionEntryDetails(
         entryName,
-        data
+        data,
+        fileName
       )
       if (entryCompletionEntryDetails) return entryCompletionEntryDetails
 

filename: packages/next/src/server/typescript/rules/config.ts
patch: @@ -22,14 +22,15 @@ const API_DOCS: Record<
     type?: string
     isValid?: (value: string) => boolean
     getHint?: (value: any) => string | undefined
+    insertText?: string
   }
 > = {
   dynamic: {
     description:
       'The `dynamic` option provides a few ways to opt in or out of dynamic behavior.',
     options: {
       '"auto"':
-        'Heuristic to cache as much as possible but doesn’t prevent any component to opt-in to dynamic behavior.',
+        "Heuristic to cache as much as possible but doesn't prevent any component to opt-in to dynamic behavior.",
       '"force-dynamic"':
         'This disables all caching of fetches and always revalidates. (This is equivalent to `getServerSideProps`.)',
       '"error"':
@@ -41,7 +42,7 @@ const API_DOCS: Record<
   },
   fetchCache: {
     description:
-      'The `fetchCache` option controls how Next.js statically caches fetches. By default it statically caches fetches reachable before any dynamic Hooks are used, and it doesn’t cache fetches that are discovered after that.',
+      "The `fetchCache` option controls how Next.js statically caches fetches. By default it statically caches fetches reachable before any dynamic Hooks are used, and it doesn't cache fetches that are discovered after that.",
     options: {
       '"force-no-store"':
         "This lets you intentionally opt-out of all caching of data. This option forces all fetches to be refetched every request even if the `cache: 'force-cache'` option is passed to `fetch()`.",
@@ -50,7 +51,7 @@ const API_DOCS: Record<
       '"default-no-store"':
         "Allows any explicit `cache` option to be passed to `fetch()` but if `'default'`, or no option, is provided then it defaults to `'no-store'`. This means that even fetches before a dynamic Hook are considered dynamic.",
       '"auto"':
-        'This is the default option. It caches any fetches with the default `cache` option provided, that happened before a dynamic Hook is used and don’t cache any such fetches if they’re issued after a dynamic Hook.',
+        "This is the default option. It caches any fetches with the default `cache` option provided, that happened before a dynamic Hook is used and don't cache any such fetches if they're issued after a dynamic Hook.",
       '"default-cache"':
         "Allows any explicit `cache` option to be passed to `fetch()` but if `'default'`, or no option, is provided then it defaults to `'force-cache'`. This means that even fetches before a dynamic Hook are considered dynamic.",
       '"only-cache"':
@@ -65,7 +66,7 @@ const API_DOCS: Record<
       'Specify the perferred region that this layout or page should be deployed to. If the region option is not specified, it inherits the option from the nearest parent layout. The root defaults to `"auto"`.\n\nYou can also specify a region, such as "iad1", or an array of regions, such as `["iad1", "sfo1"]`.',
     options: {
       '"auto"':
-        'Next.js will first deploy to the `"home"` region. Then if it doesn’t detect any waterfall requests after a few requests, it can upgrade that route, to be deployed globally. If it detects any waterfall requests after that, it can eventually downgrade back to `"home`".',
+        'Next.js will first deploy to the `"home"` region. Then if it doesn\'t detect any waterfall requests after a few requests, it can upgrade that route, to be deployed globally. If it detects any waterfall requests after that, it can eventually downgrade back to `"home`".',
       '"global"': 'Prefer deploying globally.',
       '"home"': 'Prefer deploying to the Home region.',
     },
@@ -91,7 +92,7 @@ const API_DOCS: Record<
   },
   revalidate: {
     description:
-      'The `revalidate` option sets the default revalidation time for that layout or page. Note that it doesn’t override the value specify by each `fetch()`.',
+      "The `revalidate` option sets the default revalidation time for that layout or page. Note that it doesn't override the value specify by each `fetch()`.",
     type: 'mixed',
     options: {
       false:
@@ -133,6 +134,12 @@ const API_DOCS: Record<
   metadata: {
     description: 'Next.js Metadata configurations',
     link: 'https://nextjs.org/docs/app/building-your-application/optimizing/metadata',
+    insertText: 'metadata: Metadata = {};',
+  },
+  generateMetadata: {
+    description: 'Next.js generateMetadata configurations',
+    link: 'https://nextjs.org/docs/app/api-reference/functions/generate-metadata',
+    insertText: 'generateMetadata = (): Metadata => { return {} };',
   },
   maxDuration: {
     description:
@@ -185,8 +192,10 @@ function visitEntryConfig(
 
 function createAutoCompletionOptionName(sort: number, name: string) {
   const ts = getTs()
+
   return {
     name,
+    insertText: API_DOCS[name].insertText,
     sortText: '!' + sort,
     kind: ts.ScriptElementKind.constElement,
     kindModifiers: ts.ScriptElementKindModifier.exportedModifier,
@@ -232,6 +241,7 @@ function getAPIDescription(api: string): string {
       .join('\n')
   )
 }
+
 const config = {
   // Auto completion for entry exported configs.
   addCompletionsAtPosition(
@@ -348,8 +358,9 @@ const config = {
   // Show details on the side when auto completing.
   getCompletionEntryDetails(
     entryName: string,
-    data: tsModule.CompletionEntryData
-  ) {
+    data: tsModule.CompletionEntryData,
+    fileName: string
+  ): tsModule.CompletionEntryDetails | undefined {
     const ts = getTs()
     if (
       data &&
@@ -364,6 +375,87 @@ const config = {
         if (!options) return
         content = options[entryName]
       }
+
+      if (entryName === 'metadata' || entryName === 'generateMetadata') {
+        const sourceFile = getSource(fileName)
+        let start = 0
+        let foundMetadataImport = false
+
+        if (sourceFile) {
+          const visitor: tsModule.Visitor = (node) => {
+            // Check for top directive
+            if (
+              ts.isExpressionStatement(node) &&
+              ts.isStringLiteral(node.expression) &&
+              node.expression.getStart() === 0
+            ) {
+              const text = node.expression.text
+              if (text.startsWith('use ')) {
+                start = node.end + 1
+                return node // Continue traversal
+              }
+            }
+
+            // Check for Metadata import
+            if (
+              ts.isImportDeclaration(node) &&
+              (node.moduleSpecifier.getText() === '"next"' ||
+                node.moduleSpecifier.getText() === "'next'")
+            ) {
+              const namedImports = node.importClause?.namedBindings
+              if (namedImports && ts.isNamedImports(namedImports)) {
+                foundMetadataImport = namedImports.elements.some((element) => {
+                  const name = element.name.getText()
+                  const propertyName = element.propertyName?.getText()
+                  return name === 'Metadata' || propertyName === 'Metadata'
+                })
+                if (foundMetadataImport) {
+                  return // Stop traversal
+                }
+              }
+            }
+
+            return node
+          }
+
+          for (const statement of sourceFile.statements) {
+            if (foundMetadataImport) break
+            ts.visitNode(statement, visitor)
+          }
+        }
+
+        return {
+          name: entryName,
+          kind: ts.ScriptElementKind.enumElement,
+          kindModifiers: ts.ScriptElementKindModifier.none,
+          displayParts: [],
+          codeActions: foundMetadataImport
+            ? undefined
+            : [
+                {
+                  description: `Import type 'Metadata' from module 'next'`,
+                  changes: [
+                    {
+                      fileName,
+                      textChanges: [
+                        {
+                          span: { start, length: 0 },
+                          newText: `import type { Metadata } from 'next';\n`,
+                        },
+                      ],
+                    },
+                  ],
+                },
+              ],
+          documentation: [
+            {
+              kind: 'text',
+              text: content,
+            },
+          ],
+        }
+      }
+
       return {
         name: entryName,
         kind: ts.ScriptElementKind.enumElement,