From 8539eb1643864ae7e0f5a080915cd75535f7101b Mon Sep 17 00:00:00 2001 From: Bjorn Lu Date: Thu, 13 Apr 2023 22:11:21 +0800 Subject: [PATCH] Support tsconfig aliases in CSS `@import` (#6816) --- .changeset/metal-guests-tickle.md | 5 + .../src/vite-plugin-config-alias/index.ts | 141 ++++++++++-------- packages/astro/test/alias-tsconfig.test.js | 85 ++++++++++- .../src/components/Alias.svelte | 4 + .../alias-tsconfig/src/pages/index.astro | 7 +- 5 files changed, 174 insertions(+), 68 deletions(-) create mode 100644 .changeset/metal-guests-tickle.md create mode 100644 packages/astro/test/fixtures/alias-tsconfig/src/components/Alias.svelte diff --git a/.changeset/metal-guests-tickle.md b/.changeset/metal-guests-tickle.md new file mode 100644 index 000000000000..545742583856 --- /dev/null +++ b/.changeset/metal-guests-tickle.md @@ -0,0 +1,5 @@ +--- +'astro': minor +--- + +Support tsconfig aliases in CSS `@import` diff --git a/packages/astro/src/vite-plugin-config-alias/index.ts b/packages/astro/src/vite-plugin-config-alias/index.ts index ab4547cc671a..71081c521541 100644 --- a/packages/astro/src/vite-plugin-config-alias/index.ts +++ b/packages/astro/src/vite-plugin-config-alias/index.ts @@ -1,47 +1,27 @@ -import * as path from 'path'; +import path from 'path'; import type { AstroSettings } from '../@types/astro'; +import { normalizePath, type ResolvedConfig, type Plugin as VitePlugin } from 'vite'; -import type * as vite from 'vite'; - -/** Result of successfully parsed tsconfig.json or jsconfig.json. */ -export declare interface Alias { +type Alias = { find: RegExp; replacement: string; -} - -/** Returns a path with its slashes replaced with posix slashes. */ -const normalize = (pathname: string) => String(pathname).split(path.sep).join(path.posix.sep); +}; /** Returns a list of compiled aliases. */ const getConfigAlias = (settings: AstroSettings): Alias[] | null => { - /** Closest tsconfig.json or jsconfig.json */ - const config = settings.tsConfig; - const configPath = settings.tsConfigPath; - - // if no config was found, return null - if (!config || !configPath) return null; + const { tsConfig, tsConfigPath } = settings; + if (!tsConfig || !tsConfigPath || !tsConfig.compilerOptions) return null; - /** Compiler options from tsconfig.json or jsconfig.json. */ - const compilerOptions = Object(config.compilerOptions); - - // if no compilerOptions.baseUrl was defined, return null - if (!compilerOptions.baseUrl) return null; + const { baseUrl, paths } = tsConfig.compilerOptions; + if (!baseUrl || !paths) return null; // resolve the base url from the configuration file directory - const baseUrl = path.posix.resolve( - path.posix.dirname(normalize(configPath).replace(/^\/?/, '/')), - normalize(compilerOptions.baseUrl) - ); + const resolvedBaseUrl = path.resolve(path.dirname(tsConfigPath), baseUrl); - /** List of compiled alias expressions. */ const aliases: Alias[] = []; // compile any alias expressions and push them to the list - for (let [alias, values] of Object.entries( - Object(compilerOptions.paths) as { [key: string]: string[] } - )) { - values = [].concat(values as never); - + for (const [alias, values] of Object.entries(paths)) { /** Regular Expression used to match a given path. */ const find = new RegExp( `^${[...alias] @@ -54,9 +34,9 @@ const getConfigAlias = (settings: AstroSettings): Alias[] | null => { /** Internal index used to calculate the matching id in a replacement. */ let matchId = 0; - for (let value of values) { + for (const value of values) { /** String used to replace a matched path. */ - const replacement = [...path.posix.resolve(baseUrl, value)] + const replacement = [...normalizePath(path.resolve(resolvedBaseUrl, value))] .map((segment) => (segment === '*' ? `$${++matchId}` : segment === '$' ? '$$' : segment)) .join(''); @@ -68,8 +48,10 @@ const getConfigAlias = (settings: AstroSettings): Alias[] | null => { // - `baseUrl` changes the way non-relative specifiers are resolved // - if `baseUrl` exists then all non-relative specifiers are resolved relative to it aliases.push({ - find: /^(?!\.*\/)(.+)$/, - replacement: `${[...baseUrl].map((segment) => (segment === '$' ? '$$' : segment)).join('')}/$1`, + find: /^(?!\.*\/|\w:)(.+)$/, + replacement: `${[...normalizePath(resolvedBaseUrl)] + .map((segment) => (segment === '$' ? '$$' : segment)) + .join('')}/$1`, }); return aliases; @@ -80,40 +62,79 @@ export default function configAliasVitePlugin({ settings, }: { settings: AstroSettings; -}): vite.PluginOption { - const { config } = settings; - /** Aliases from the tsconfig.json or jsconfig.json configuration. */ +}): VitePlugin | null { const configAlias = getConfigAlias(settings); + if (!configAlias) return null; - // if no config alias was found, bypass this plugin - if (!configAlias) return {} as vite.PluginOption; - - return { + const plugin: VitePlugin = { name: 'astro:tsconfig-alias', enforce: 'pre', - async resolveId(sourceId: string, importer, options) { - /** Resolved ID conditionally handled by any other resolver. (this gives priority to all other resolvers) */ - const resolvedId = await this.resolve(sourceId, importer, { skipSelf: true, ...options }); - - // if any other resolver handles the file, return that resolution - if (resolvedId) return resolvedId; + configResolved(config) { + patchCreateResolver(config, plugin); + }, + async resolveId(id, importer, options) { + if (isVirtualId(id)) return; - // conditionally resolve the source ID from any matching alias or baseUrl + // Handle aliases found from `compilerOptions.paths`. Unlike Vite aliases, tsconfig aliases + // are best effort only, so we have to manually replace them here, instead of using `vite.resolve.alias` for (const alias of configAlias) { - if (alias.find.test(sourceId)) { - /** Processed Source ID with our alias applied. */ - const aliasedSourceId = sourceId.replace(alias.find, alias.replacement); - - /** Resolved ID conditionally handled by any other resolver. (this also gives priority to all other resolvers) */ - const resolvedAliasedId = await this.resolve(aliasedSourceId, importer, { - skipSelf: true, - ...options, - }); - - // if the existing resolvers find the file, return that resolution - if (resolvedAliasedId) return resolvedAliasedId; + if (alias.find.test(id)) { + const updatedId = id.replace(alias.find, alias.replacement); + const resolved = await this.resolve(updatedId, importer, { skipSelf: true, ...options }); + if (resolved) return resolved; } } }, }; + + return plugin; +} + +/** + * 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`. + * + * Vite may simplify this soon: https://github.com/vitejs/vite/pull/10555 + */ +function patchCreateResolver(config: ResolvedConfig, prePlugin: VitePlugin) { + const _createResolver = config.createResolver; + // @ts-expect-error override readonly property intentionally + config.createResolver = function (...args1: any) { + const resolver = _createResolver.apply(config, args1); + return async function (...args2: any) { + const id: string = args2[0]; + const importer: string | undefined = args2[1]; + const ssr: boolean | undefined = args2[3]; + + // fast path so we don't run this extensive logic in prebundling + if (importer?.includes('node_modules')) { + return resolver.apply(_createResolver, args2); + } + + const fakePluginContext = { + resolve: (_id: string, _importer?: string) => resolver(_id, _importer, false, ssr), + }; + const fakeResolveIdOpts = { + assertions: {}, + isEntry: false, + ssr, + }; + + // @ts-expect-error resolveId exists + const resolved = await prePlugin.resolveId.apply(fakePluginContext, [ + id, + importer, + fakeResolveIdOpts, + ]); + if (resolved) return resolved; + + return resolver.apply(_createResolver, args2); + }; + }; +} + +function isVirtualId(id: string) { + return id.includes('\0') || id.startsWith('virtual:') || id.startsWith('astro:'); } diff --git a/packages/astro/test/alias-tsconfig.test.js b/packages/astro/test/alias-tsconfig.test.js index 07dd1fe23948..84dd50c2f785 100644 --- a/packages/astro/test/alias-tsconfig.test.js +++ b/packages/astro/test/alias-tsconfig.test.js @@ -1,18 +1,38 @@ import { expect } from 'chai'; import * as cheerio from 'cheerio'; -import { isWindows, loadFixture } from './test-utils.js'; +import { loadFixture } from './test-utils.js'; describe('Aliases with tsconfig.json', () => { 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({ root: './fixtures/alias-tsconfig/', }); }); - if (isWindows) return; - describe('dev', () => { let devServer; @@ -50,13 +70,66 @@ describe('Aliases with tsconfig.json', () => { expect($('#namespace').text()).to.equal('namespace'); }); - // TODO: fix this https://github.com/withastro/astro/issues/6551 - it.skip('works in css @import', async () => { + it('works in css @import', async () => { const html = await fixture.fetch('/').then((res) => res.text()); - console.log(html); // imported css should be bundled expect(html).to.include('#style-red'); expect(html).to.include('#style-blue'); }); + + it('works in components', async () => { + const html = await fixture.fetch('/').then((res) => res.text()); + const $ = cheerio.load(html); + + expect($('#alias').text()).to.equal('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 + expect($('#client').text()).to.equal('test'); + + const scripts = $('script').toArray(); + expect(scripts.length).to.be.greaterThan(0); + }); + + it('can load via baseUrl', async () => { + const html = await fixture.readFile('/index.html'); + const $ = cheerio.load(html); + + expect($('#foo').text()).to.equal('foo'); + expect($('#constants-foo').text()).to.equal('foo'); + }); + + it('can load namespace packages with @* paths', async () => { + const html = await fixture.readFile('/index.html'); + const $ = cheerio.load(html); + + expect($('#namespace').text()).to.equal('namespace'); + }); + + 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 + expect(css).to.include('#style-red'); + expect(css).to.include('#style-blue'); + }); + + it('works in components', async () => { + const html = await fixture.readFile('/index.html'); + const $ = cheerio.load(html); + + expect($('#alias').text()).to.equal('foo'); + }); }); }); diff --git a/packages/astro/test/fixtures/alias-tsconfig/src/components/Alias.svelte b/packages/astro/test/fixtures/alias-tsconfig/src/components/Alias.svelte new file mode 100644 index 000000000000..066885bd9690 --- /dev/null +++ b/packages/astro/test/fixtures/alias-tsconfig/src/components/Alias.svelte @@ -0,0 +1,4 @@ + +
{foo}
diff --git a/packages/astro/test/fixtures/alias-tsconfig/src/pages/index.astro b/packages/astro/test/fixtures/alias-tsconfig/src/pages/index.astro index 00dc44b92c63..a3feb613bd25 100644 --- a/packages/astro/test/fixtures/alias-tsconfig/src/pages/index.astro +++ b/packages/astro/test/fixtures/alias-tsconfig/src/pages/index.astro @@ -2,10 +2,10 @@ import Client from '@components/Client.svelte' import Foo from 'src/components/Foo.astro'; import StyleComp from 'src/components/Style.astro'; +import Alias from '@components/Alias.svelte'; import { namespace } from '@test/namespace-package' import { foo } from 'src/utils/constants'; -// TODO: support alias in @import https://github.com/withastro/astro/issues/6551 -// import '@styles/main.css'; +import '@styles/main.css'; --- @@ -18,8 +18,11 @@ import { foo } from 'src/utils/constants'; +

{namespace}

{foo}

+

style-red

+

style-blue