Skip to content
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
5 changes: 5 additions & 0 deletions .changeset/smart-pots-shave.md
Original file line number Diff line number Diff line change
@@ -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.
58 changes: 58 additions & 0 deletions packages/language-tools/ts-plugin/src/astro-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 7 additions & 5 deletions packages/language-tools/ts-plugin/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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-'));
Expand Down Expand Up @@ -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);
Expand Down
Loading