Skip to content

[ts-next-plugin] auto import metadata type #78258

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Apr 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion packages/next/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,6 @@
"@types/ua-parser-js": "0.7.36",
"@types/webpack-sources1": "npm:@types/webpack-sources@0.1.5",
"@types/ws": "8.2.0",
"@typescript/vfs": "1.6.1",
"@vercel/ncc": "0.34.0",
"@vercel/nft": "0.27.1",
"@vercel/turbopack-ecmascript-runtime": "*",
Expand Down
17 changes: 0 additions & 17 deletions packages/next/src/compiled/@typescript/vfs/LICENSE

This file was deleted.

1 change: 0 additions & 1 deletion packages/next/src/compiled/@typescript/vfs/index.js

This file was deleted.

1 change: 0 additions & 1 deletion packages/next/src/compiled/@typescript/vfs/package.json

This file was deleted.

21 changes: 0 additions & 21 deletions packages/next/src/compiled/@typescript/vfs/typescript.js

This file was deleted.

50 changes: 9 additions & 41 deletions packages/next/src/server/typescript/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,15 @@ export const createTSPlugin: tsModule.server.PluginModuleFactory = ({
// The default user config is { "name": "next" }
const isPluginEnabled = info.config.enabled ?? true

const isPluginInitialized = init({
if (!isPluginEnabled) {
return info.languageService
}

init({
ts,
info,
})

if (!isPluginEnabled || !isPluginInitialized) {
return info.languageService
}

// Set up decorator object
const proxy: tsModule.LanguageService = Object.create(null)
for (let k of Object.keys(info.languageService)) {
Expand Down Expand Up @@ -79,14 +79,6 @@ export const createTSPlugin: tsModule.server.PluginModuleFactory = ({
if (!entryInfo.client) {
// Remove specified entries from completion list
prior.entries = serverLayer.filterCompletionsAtPosition(prior.entries)

// Provide autocompletion for metadata fields
prior = metadata.filterCompletionsAtPosition(
fileName,
position,
options,
prior
)
}

// Add auto completions for export configs.
Expand Down Expand Up @@ -126,21 +118,11 @@ export const createTSPlugin: tsModule.server.PluginModuleFactory = ({
) => {
const entryCompletionEntryDetails = entryConfig.getCompletionEntryDetails(
entryName,
data
data,
fileName
)
if (entryCompletionEntryDetails) return entryCompletionEntryDetails

const metadataCompletionEntryDetails = metadata.getCompletionEntryDetails(
fileName,
position,
entryName,
formatOptions,
source,
preferences,
data
)
if (metadataCompletionEntryDetails) return metadataCompletionEntryDetails

return info.languageService.getCompletionEntryDetails(
fileName,
position,
Expand Down Expand Up @@ -173,9 +155,6 @@ export const createTSPlugin: tsModule.server.PluginModuleFactory = ({
) {
return
}

const metadataInfo = metadata.getQuickInfoAtPosition(fileName, position)
if (metadataInfo) return metadataInfo
}

const overridden = entryConfig.getQuickInfoAtPosition(fileName, position)
Expand Down Expand Up @@ -248,10 +227,7 @@ export const createTSPlugin: tsModule.server.PluginModuleFactory = ({
fileName,
node
)
: metadata.getSemanticDiagnosticsForExportVariableStatement(
fileName,
node
)
: []
prior.push(...diagnostics, ...metadataDiagnostics)
}

Expand Down Expand Up @@ -311,10 +287,7 @@ export const createTSPlugin: tsModule.server.PluginModuleFactory = ({
fileName,
node
)
: metadata.getSemanticDiagnosticsForExportVariableStatement(
fileName,
node
)
: []
prior.push(...metadataDiagnostics)
}

Expand Down Expand Up @@ -368,11 +341,6 @@ export const createTSPlugin: tsModule.server.PluginModuleFactory = ({
proxy.getDefinitionAndBoundSpan = (fileName: string, position: number) => {
const entryInfo = getEntryInfo(fileName)
if (isAppEntryFile(fileName) && !entryInfo.client) {
const metadataDefinition = metadata.getDefinitionAndBoundSpan(
fileName,
position
)
if (metadataDefinition) return metadataDefinition
}

return info.languageService.getDefinitionAndBoundSpan(fileName, position)
Expand Down
106 changes: 99 additions & 7 deletions packages/next/src/server/typescript/rules/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 doesnt 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"':
Expand All @@ -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 doesnt 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()`.",
Expand All @@ -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 dont cache any such fetches if theyre 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"':
Expand All @@ -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 doesnt 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.',
},
Expand All @@ -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 doesnt 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:
Expand Down Expand Up @@ -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 {} };',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we already do arrow expression, might as well add a dedicated GenerateMetadata type that includes the doc link in its jsdoc.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The description in the JSDoc will be the same content as the description from the quick info. I think it's redundant because this arrow function addition will occur when the plugin is enabled, which means the quick info will already be enabled to display the description.

If we consider displaying JSDoc when this plugin is disabled, I think we should follow up if needed.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JSDoc is supported by more than VSCode. I don't think this is redundant. JSDoc is displayed when you hover a value with that type not for autocomplete.

Copy link
Member Author

@devjiwonchoi devjiwonchoi Apr 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did mean hovering as "quick info":

CleanShot 2025-04-22 at 19 16 34@2x

Hovering on generateMetadata can already show the content that will appear on the GenerateMetadata type.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you include an example that I can play around with?

Copy link
Member Author

@devjiwonchoi devjiwonchoi Apr 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will follow up from here: #78409

},
maxDuration: {
description:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -232,6 +241,7 @@ function getAPIDescription(api: string): string {
.join('\n')
)
}

const config = {
// Auto completion for entry exported configs.
addCompletionsAtPosition(
Expand Down Expand Up @@ -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 &&
Expand All @@ -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,
Expand Down
Loading
Loading