diff --git a/packages/astro/src/vite-plugin-config-alias/index.ts b/packages/astro/src/vite-plugin-config-alias/index.ts index f06127a8e720..1ebd279b2bf0 100644 --- a/packages/astro/src/vite-plugin-config-alias/index.ts +++ b/packages/astro/src/vite-plugin-config-alias/index.ts @@ -1,6 +1,8 @@ +import fs from 'node:fs'; import path from 'node:path'; import type { CompilerOptions } from 'typescript'; import { normalizePath, type ResolvedConfig, type Plugin as VitePlugin } from 'vite'; + import type { AstroSettings } from '../types/astro.js'; type Alias = { @@ -65,6 +67,51 @@ const getConfigAlias = (settings: AstroSettings): Alias[] | null => { return aliases; }; +/** Generate vite.resolve.alias entries from tsconfig paths */ +const getViteResolveAlias = (settings: AstroSettings) => { + const { tsConfig, tsConfigPath } = settings; + if (!tsConfig || !tsConfigPath || !tsConfig.compilerOptions) return []; + + const { baseUrl, paths } = tsConfig.compilerOptions as CompilerOptions; + const effectiveBaseUrl = baseUrl ?? (paths ? '.' : undefined); + if (!effectiveBaseUrl) return []; + + const resolvedBaseUrl = path.resolve(path.dirname(tsConfigPath), effectiveBaseUrl); + const aliases: Array<{ find: string | RegExp; replacement: string; customResolver?: any }> = []; + + // Build aliases with custom resolver that tries multiple paths + if (paths) { + for (const [aliasPattern, values] of Object.entries(paths)) { + const resolvedValues = values.map((v) => path.resolve(resolvedBaseUrl, v)); + + const customResolver = (id: string) => { + // Try each path in order + // id is already the wildcard part (e.g., 'extra.css' for '@styles/*') + // resolvedValues still have the * in them, so replace * with id + for (const resolvedValue of resolvedValues) { + const resolved = resolvedValue.replace('*', id); + if (fs.existsSync(resolved)) { + return resolved; + } + } + return null; + }; + + aliases.push({ + // Build regex from alias pattern (e.g., '@styles/*' -> /^@styles\/(.+)$/) + // First, escape special regex chars. Then replace * with a capture group (.+) + find: new RegExp( + `^${aliasPattern.replace(/[\\^$+?.()|[\]{}]/g, '\\$&').replace(/\*/g, '(.+)')}$`, + ), + replacement: aliasPattern.includes('*') ? '$1' : aliasPattern, + customResolver, + }); + } + } + + return aliases; +}; + /** Returns a Vite plugin used to alias paths from tsconfig.json and jsconfig.json. */ export default function configAliasVitePlugin({ settings, @@ -78,6 +125,14 @@ export default function configAliasVitePlugin({ name: 'astro:tsconfig-alias', // use post to only resolve ids that all other plugins before it can't enforce: 'post', + config() { + // Return vite.resolve.alias config with custom resolvers + return { + resolve: { + alias: getViteResolveAlias(settings), + }, + }; + }, configResolved(config) { patchCreateResolver(config, plugin); }, @@ -109,11 +164,12 @@ export default function configAliasVitePlugin({ /** * Vite's `createResolver` is used to resolve various things, including CSS `@import`. - * However, there's no way to extend this resolver, besides patching it. This function - * patches and adds a Vite plugin whose `resolveId` will be used to resolve before the - * internal plugins in `createResolver`. + * We use vite.resolve.alias with custom resolvers to handle tsconfig paths in most cases, + * but for CSS imports, we still need to patch createResolver as vite.resolve.alias + * doesn't apply there. This function patches createResolver to inject our custom resolver. * - * Vite may simplify this soon: https://github.com/vitejs/vite/pull/10555 + * TODO: Remove this function once all tests pass with only the vite.resolve.alias approach, + * which means CSS @import resolution will work without patching createResolver. */ function patchCreateResolver(config: ResolvedConfig, postPlugin: VitePlugin) { const _createResolver = config.createResolver; diff --git a/packages/astro/test/alias-tsconfig-baseurl-only.test.js b/packages/astro/test/alias-tsconfig-baseurl-only.test.js deleted file mode 100644 index ecf1dea24084..000000000000 --- a/packages/astro/test/alias-tsconfig-baseurl-only.test.js +++ /dev/null @@ -1,126 +0,0 @@ -import assert from 'node:assert/strict'; -import { after, before, describe, it } from 'node:test'; -import * as cheerio from 'cheerio'; -import { loadFixture } from './test-utils.js'; - -describe('Aliases with tsconfig.json - baseUrl only', () => { - let fixture; - - /** - * @param {string} html - * @returns {string[]} - */ - function getLinks(html) { - let $ = cheerio.load(html); - let out = []; - $('link[rel=stylesheet]').each((_i, el) => { - out.push($(el).attr('href')); - }); - return out; - } - - /** - * @param {string} href - * @returns {Promise<{ href: string; css: string; }>} - */ - async function getLinkContent(href, f = fixture) { - const css = await f.readFile(href); - return { href, css }; - } - - before(async () => { - fixture = await loadFixture({ - // test suite was authored when inlineStylesheets defaulted to never - build: { inlineStylesheets: 'never' }, - root: './fixtures/alias-tsconfig-baseurl-only/', - }); - }); - - describe('dev', () => { - let devServer; - - before(async () => { - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - it('can load client components', async () => { - const html = await fixture.fetch('/').then((res) => res.text()); - const $ = cheerio.load(html); - - // Should render aliased element - assert.equal($('#client').text(), 'test'); - - const scripts = $('script').toArray(); - assert.ok(scripts.length > 0); - }); - - it('can load via baseUrl', async () => { - const html = await fixture.fetch('/').then((res) => res.text()); - const $ = cheerio.load(html); - - assert.equal($('#foo').text(), 'foo'); - assert.equal($('#constants-foo').text(), 'foo'); - assert.equal($('#constants-index').text(), 'index'); - }); - - it('works in css @import', async () => { - const html = await fixture.fetch('/').then((res) => res.text()); - // imported css should be bundled - assert.ok(html.includes('#style-red')); - assert.ok(html.includes('#style-blue')); - }); - - it('works in components', async () => { - const html = await fixture.fetch('/').then((res) => res.text()); - const $ = cheerio.load(html); - - assert.equal($('#alias').text(), 'foo'); - }); - }); - - describe('build', () => { - before(async () => { - await fixture.build(); - }); - - it('can load client components', async () => { - const html = await fixture.readFile('/index.html'); - const $ = cheerio.load(html); - - // Should render aliased element - assert.equal($('#client').text(), 'test'); - - const scripts = $('script').toArray(); - assert.ok(scripts.length > 0); - }); - - it('can load via baseUrl', async () => { - const html = await fixture.readFile('/index.html'); - const $ = cheerio.load(html); - - assert.equal($('#foo').text(), 'foo'); - assert.equal($('#constants-foo').text(), 'foo'); - assert.equal($('#constants-index').text(), 'index'); - }); - - it('works in css @import', async () => { - const html = await fixture.readFile('/index.html'); - const content = await Promise.all(getLinks(html).map((href) => getLinkContent(href))); - const [{ css }] = content; - // imported css should be bundled - assert.ok(css.includes('#style-red')); - assert.ok(css.includes('#style-blue')); - }); - - it('works in components', async () => { - const html = await fixture.readFile('/index.html'); - const $ = cheerio.load(html); - - assert.equal($('#alias').text(), 'foo'); - }); - }); -});