Support tsconfig aliases in CSS @import (#6816)

This commit is contained in:
Bjorn Lu 2023-04-13 22:11:21 +08:00 committed by GitHub
parent c464bf258f
commit 8539eb1643
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 174 additions and 68 deletions

View file

@ -0,0 +1,5 @@
---
'astro': minor
---
Support tsconfig aliases in CSS `@import`

View file

@ -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;
const { tsConfig, tsConfigPath } = settings;
if (!tsConfig || !tsConfigPath || !tsConfig.compilerOptions) return null;
// if no config was found, return null
if (!config || !configPath) 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 });
configResolved(config) {
patchCreateResolver(config, plugin);
},
async resolveId(id, importer, options) {
if (isVirtualId(id)) return;
// if any other resolver handles the file, return that resolution
if (resolvedId) return resolvedId;
// 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:');
}

View file

@ -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');
});
});
});

View file

@ -0,0 +1,4 @@
<script>
import { foo } from 'src/utils/constants';
</script>
<div id="alias">{foo}</div>

View file

@ -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';
---
<html lang="en">
<head>
@ -18,8 +18,11 @@ import { foo } from 'src/utils/constants';
<Client client:load />
<Foo />
<StyleComp />
<Alias client:load />
<p id="namespace">{namespace}</p>
<p id="constants-foo">{foo}</p>
<p id="style-red">style-red</p>
<p id="style-blue">style-blue</p>
</main>
</body>
</html>