From 4cdd8a20f5b0f5f3dbf3f9c49228b6562632e491 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Sat, 31 Jan 2026 02:13:37 +0800 Subject: [PATCH 1/2] feat: use `module-replacements` --- package.json | 1 + pnpm-lock.yaml | 3 +++ tsdown.config.ts | 1 + 3 files changed, 5 insertions(+) diff --git a/package.json b/package.json index 4241413..71e87e6 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "husky": "^9.1.7", "jsonc-parser": "^3.3.1", "lru-cache": "^11.2.5", + "module-replacements": "^2.11.0", "nano-staged": "^0.9.0", "ofetch": "^2.0.0-alpha.3", "reactive-vscode": "^0.4.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 02500a2..24ca39f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: lru-cache: specifier: ^11.2.5 version: 11.2.5 + module-replacements: + specifier: ^2.11.0 + version: 2.11.0 nano-staged: specifier: ^0.9.0 version: 0.9.0 diff --git a/tsdown.config.ts b/tsdown.config.ts index cbdcc46..adceb9f 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -16,6 +16,7 @@ export default defineConfig({ 'yaml', 'ofetch', 'lru-cache', + 'module-replacements', ], minify: 'dce-only', }) From aafe5090b3f3292442964970b9da6c7d914ee28a Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Sun, 1 Feb 2026 00:31:05 +0800 Subject: [PATCH 2/2] done --- src/constants.ts | 1 + src/providers/diagnostics/index.ts | 9 ++++-- .../diagnostics/rules/replacement.ts | 31 +++++++++++++++++++ src/utils/npm.ts | 4 +-- src/utils/replacement.ts | 29 +++++++++++++++++ tsdown.config.ts | 1 - 6 files changed, 70 insertions(+), 5 deletions(-) create mode 100644 src/providers/diagnostics/rules/replacement.ts create mode 100644 src/utils/replacement.ts diff --git a/src/constants.ts b/src/constants.ts index 5e8d534..be39b23 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -7,3 +7,4 @@ export const PNPM_WORKSPACE_PATTERN = `**/${PNPM_WORKSPACE_BASENAME}` export const VERSION_TRIGGER_CHARACTERS = ['.', '^', '~', ...Array.from({ length: 10 }).map((_, i) => `${i}`)] export const NPM_REGISTRY = 'https://registry.npmjs.org' +export const NPMX_DEV_API = 'https://npmx.dev/api' diff --git a/src/providers/diagnostics/index.ts b/src/providers/diagnostics/index.ts index f41e4f7..8c80c0d 100644 --- a/src/providers/diagnostics/index.ts +++ b/src/providers/diagnostics/index.ts @@ -1,5 +1,6 @@ import type { DependencyInfo, Extractor, ValidNode } from '#types/extractor' import type { ResolvedPackument } from '#utils/npm' +import type { Awaitable } from 'reactive-vscode' import type { Diagnostic, TextDocument } from 'vscode' import { basename } from 'node:path' import { logger } from '#state' @@ -8,14 +9,16 @@ import { useActiveTextEditor, useDocumentText, watch } from 'reactive-vscode' import { languages } from 'vscode' import { displayName } from '../../generated-meta' import { checkDeprecation } from './rules/deprecation' +import { checkReplacement } from './rules/replacement' export interface NodeDiagnosticInfo extends Pick { node: ValidNode } -export type DiagnosticRule = (dep: DependencyInfo, pkg: ResolvedPackument) => NodeDiagnosticInfo | undefined +export type DiagnosticRule = (dep: DependencyInfo, pkg: ResolvedPackument) => Awaitable const rules: DiagnosticRule[] = [ checkDeprecation, + checkReplacement, ] export function registerDiagnosticCollection(mapping: Record) { @@ -38,7 +41,7 @@ export function registerDiagnosticCollection(mapping: Record 0) + logger.info(`${diagnostics.length} diagnostic collected in ${document.fileName}.`) diagnosticCollection.set(document.uri, diagnostics) } diff --git a/src/providers/diagnostics/rules/replacement.ts b/src/providers/diagnostics/rules/replacement.ts new file mode 100644 index 0000000..a7b7916 --- /dev/null +++ b/src/providers/diagnostics/rules/replacement.ts @@ -0,0 +1,31 @@ +import type { ModuleReplacement } from 'module-replacements' +import type { DiagnosticRule } from '..' +import { getReplacement } from '#utils/replacement' +import { DiagnosticSeverity } from 'vscode' + +// https://github.com/npmx-dev/npmx.dev/blob/main/app/components/PackageReplacement.vue#L8-L30 +function generateMessage(replacement: ModuleReplacement) { + switch (replacement.type) { + case 'native': + return `This can be replaced with ${replacement.replacement}, available since Node ${replacement.nodeVersion}.` + case 'simple': + return `The community has flagged this package as redundant, with the advice: ${replacement.replacement}.` + case 'documented': + return 'The community has flagged this package as having more performant alternatives.' + case 'none': + return 'This package has been flagged as no longer needed, and its functionality is likely available natively in all engines.' + } +} + +export const checkReplacement: DiagnosticRule = async (dep) => { + const replacement = await getReplacement(dep.name) + // Fallback for cache compatibility (LRUCache rejects null/undefined) + if (!replacement || !('type' in replacement)) + return + + return { + node: dep.nameNode, + message: generateMessage(replacement), + severity: DiagnosticSeverity.Warning, + } +} diff --git a/src/utils/npm.ts b/src/utils/npm.ts index 5fd892e..b09a097 100644 --- a/src/utils/npm.ts +++ b/src/utils/npm.ts @@ -18,7 +18,7 @@ export interface ResolvedPackument { * Encode a package name for use in npm registry URLs. * Handles scoped packages (e.g., @scope/name -> @scope%2Fname). */ -function encodePackageName(name: string): string { +export function encodePackageName(name: string): string { if (name.startsWith('@')) { return `@${encodeURIComponent(name.slice(1))}` } @@ -33,7 +33,7 @@ const cache = new LRUCache({ fetchMethod: async (name, staleValue, { signal }) => { const encodedName = encodePackageName(name) - logger.info(`fetching ${name}...`) + logger.info(`fetching package info for ${name}...`) const pkg = await ofetch(`${NPM_REGISTRY}/${encodedName}`, { signal }) const resolvedVersions = Object.fromEntries( diff --git a/src/utils/replacement.ts b/src/utils/replacement.ts new file mode 100644 index 0000000..42db430 --- /dev/null +++ b/src/utils/replacement.ts @@ -0,0 +1,29 @@ +import type { ModuleReplacement } from 'module-replacements' +import { NPMX_DEV_API } from '#constants' +import { logger } from '#state' +import { LRUCache } from 'lru-cache' +import { ofetch } from 'ofetch' +import { encodePackageName } from './npm' + +const cache = new LRUCache({ + max: 500, + ttl: 60 * 60 * 1000, + updateAgeOnGet: true, + allowStale: true, + fetchMethod: async (name, staleValue, { signal }) => { + const encodedName = encodePackageName(name) + + logger.info(`fetching replacement for ${name}...`) + try { + return await ofetch(`${NPMX_DEV_API}/replacements/${encodedName}`, { signal }) + // Fallback for cache compatibility (LRUCache rejects null/undefined) + ?? {} + } catch (err) { + logger.warn('fetching replacement error: ', err) + } + }, +}) + +export async function getReplacement(name: string) { + return (await cache.fetch(name))! +} diff --git a/tsdown.config.ts b/tsdown.config.ts index adceb9f..cbdcc46 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -16,7 +16,6 @@ export default defineConfig({ 'yaml', 'ofetch', 'lru-cache', - 'module-replacements', ], minify: 'dce-only', })