Skip to content

Commit

Permalink
Support tsconfig aliases in CSS @import (#6816)
Browse files Browse the repository at this point in the history
  • Loading branch information
bluwy authored Apr 13, 2023
1 parent c464bf2 commit 8539eb1
Show file tree
Hide file tree
Showing 5 changed files with 174 additions and 68 deletions.
5 changes: 5 additions & 0 deletions .changeset/metal-guests-tickle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': minor
---

Support tsconfig aliases in CSS `@import`
141 changes: 81 additions & 60 deletions packages/astro/src/vite-plugin-config-alias/index.ts
Original file line number Diff line number Diff line change
@@ -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]
Expand All @@ -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('');

Expand All @@ -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;
Expand All @@ -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:');
}
85 changes: 79 additions & 6 deletions packages/astro/test/alias-tsconfig.test.js
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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');
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<script>
import { foo } from 'src/utils/constants';
</script>
<div id="alias">{foo}</div>
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand All @@ -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>

0 comments on commit 8539eb1

Please sign in to comment.