diff --git a/.changeset/smart-pots-shave.md b/.changeset/smart-pots-shave.md new file mode 100644 index 000000000000..5dc345805ca3 --- /dev/null +++ b/.changeset/smart-pots-shave.md @@ -0,0 +1,5 @@ +--- +'@astrojs/ts-plugin': patch +--- + +Fixes Astro's ambient types leaking into unrelated TypeScript projects. In a monorepo with hoisted `node_modules`, the plugin found the shared `astro` install from any project and injected `env.d.ts` and `astro-jsx.d.ts` into it, which pulled `@types/node` into projects that never asked for it. The plugin now only injects those types when the project actually depends on `astro` or has an `astro.config.*` file. diff --git a/packages/language-tools/ts-plugin/src/astro-types.ts b/packages/language-tools/ts-plugin/src/astro-types.ts index 3a04959dae19..b966d031b9c1 100644 --- a/packages/language-tools/ts-plugin/src/astro-types.ts +++ b/packages/language-tools/ts-plugin/src/astro-types.ts @@ -39,6 +39,64 @@ function findAstroPackageDirectoryFrom( } } +/** + * Check whether a directory belongs to an Astro project, by looking for an `astro` + * dependency in the nearest `package.json` and falling back to an `astro.config.*` file + * next to it. Mirrors the language server's `getAstroInstall()` check. + */ +export function isAstroProject( + tsModule: typeof import('typescript'), + currentDirectory: string, +): boolean { + const packageJson = findNearestPackageJson(tsModule, currentDirectory); + if (!packageJson) { + return true; + } + + try { + const content = JSON.parse(tsModule.sys.readFile(packageJson) ?? '{}'); + const deps = [ + ...Object.keys(content.dependencies ?? {}), + ...Object.keys(content.devDependencies ?? {}), + ...Object.keys(content.peerDependencies ?? {}), + ]; + + if (deps.includes('astro')) { + return true; + } + } catch {} + + return tsModule.sys + .readDirectory( + path.dirname(packageJson), + ['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts'], + undefined, + undefined, + 1, + ) + .some((file) => path.basename(file).startsWith('astro.config')); +} + +function findNearestPackageJson( + tsModule: typeof import('typescript'), + currentDirectory: string, +): string | undefined { + let directory = tsModule.sys.resolvePath(currentDirectory); + + while (true) { + const packageJson = path.join(directory, 'package.json'); + if (tsModule.sys.fileExists(packageJson)) { + return packageJson; + } + + const parent = path.dirname(directory); + if (parent === directory) { + return undefined; + } + directory = parent; + } +} + /** * Inject the installed Astro package's `env.d.ts` and `astro-jsx.d.ts` into the * TypeScript program. Without these, the `Astro` global is undeclared and the type diff --git a/packages/language-tools/ts-plugin/src/index.ts b/packages/language-tools/ts-plugin/src/index.ts index 758c5f917e39..8da9d4587475 100644 --- a/packages/language-tools/ts-plugin/src/index.ts +++ b/packages/language-tools/ts-plugin/src/index.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import type { LanguagePlugin } from '@volar/language-core'; import { createLanguageServicePlugin } from '@volar/typescript/lib/quickstart/createLanguageServicePlugin.js'; -import { addAstroTypes } from './astro-types.js'; +import { addAstroTypes, isAstroProject } from './astro-types.js'; import type { CollectionConfig } from './frontmatter.js'; import { getFrontmatterLanguagePlugin } from './frontmatter.js'; import { getLanguagePlugin } from './language.js'; @@ -13,10 +13,12 @@ export = createLanguageServicePlugin((ts, info) => { // Make "Go To References" from `.ts` files aware of usages inside `.astro` files // by injecting the Astro ambient types so type chains like `Astro.locals.*` resolve. // (`.astro` files themselves already enter the program via Volar's external files.) - addAstroTypes(ts, info.languageServiceHost, [ - currentDir, - ...info.languageServiceHost.getScriptFileNames().map((fileName) => path.dirname(fileName)), - ]); + if (isAstroProject(ts, currentDir)) { + addAstroTypes(ts, info.languageServiceHost, [ + currentDir, + ...info.languageServiceHost.getScriptFileNames().map((fileName) => path.dirname(fileName)), + ]); + } try { const fileContent = ts.sys.readFile(currentDir + '/.astro/collections/collections.json'); diff --git a/packages/language-tools/ts-plugin/test/units/astro-types.test.mts b/packages/language-tools/ts-plugin/test/units/astro-types.test.mts index 0668f81430bf..026f10454bd6 100644 --- a/packages/language-tools/ts-plugin/test/units/astro-types.test.mts +++ b/packages/language-tools/ts-plugin/test/units/astro-types.test.mts @@ -5,7 +5,7 @@ import os from 'node:os'; import path from 'node:path'; import ts from 'typescript'; import { astro2tsx } from '../../src/astro2tsx.js'; -import { addAstroTypes } from '../../src/astro-types.js'; +import { addAstroTypes, isAstroProject } from '../../src/astro-types.js'; function createFixture() { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'astro-ts-plugin-')); @@ -111,6 +111,66 @@ function findToUpperReferenceFiles(injectAstroTypes: boolean) { } } +function createHoistedMonorepo() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'astro-ts-plugin-monorepo-')); + const astroPackage = path.join(root, 'node_modules', 'astro'); + const docs = path.join(root, 'apps', 'docs'); + const frontend = path.join(root, 'apps', 'frontend'); + const standalone = path.join(root, 'apps', 'standalone'); + + fs.mkdirSync(astroPackage, { recursive: true }); + fs.mkdirSync(docs, { recursive: true }); + fs.mkdirSync(frontend, { recursive: true }); + fs.mkdirSync(standalone, { recursive: true }); + + fs.writeFileSync(path.join(astroPackage, 'package.json'), '{"name":"astro","version":"6.0.0"}'); + fs.writeFileSync(path.join(root, 'package.json'), '{"name":"monorepo","private":true}'); + fs.writeFileSync( + path.join(docs, 'package.json'), + '{"name":"docs","dependencies":{"astro":"^6.0.0"}}', + ); + fs.writeFileSync( + path.join(frontend, 'package.json'), + '{"name":"frontend","dependencies":{"react":"^19.0.0"}}', + ); + fs.writeFileSync(path.join(standalone, 'package.json'), '{"name":"standalone"}'); + fs.writeFileSync(path.join(standalone, 'astro.config.mjs'), 'export default {};\n'); + + return { root, docs, frontend, standalone }; +} + +suite('Astro project detection', () => { + test('skips a project that only reaches Astro through a hoisted node_modules', () => { + const monorepo = createHoistedMonorepo(); + + try { + assert.strictEqual(isAstroProject(ts, monorepo.frontend), false); + } finally { + fs.rmSync(monorepo.root, { recursive: true, force: true }); + } + }); + + test('detects a project that depends on Astro', () => { + const monorepo = createHoistedMonorepo(); + + try { + assert.strictEqual(isAstroProject(ts, monorepo.docs), true); + } finally { + fs.rmSync(monorepo.root, { recursive: true, force: true }); + } + }); + + test('detects a project with an Astro config but no Astro dependency', () => { + const monorepo = createHoistedMonorepo(); + + try { + assert.strictEqual(isAstroProject(ts, monorepo.standalone), true); + } finally { + fs.rmSync(monorepo.root, { recursive: true, force: true }); + } + }); +}); + suite('Astro type injection', () => { test('reproduces the missing Astro.locals reference without Astro types', () => { const referencesWithoutAstroTypes = findToUpperReferenceFiles(false);