Upgrade: Rewrite imports of relative files to use relative file paths (#14755)

When we implemented the CSS import resolution system, we found out a
detail about CSS imports in that files without a relative path prefix
would still be relative to the source file. E.g.:

```css
@import 'foo.css';
```

Should first look for the file `foo.css` in the same directory. To make
this cost as cheap as possible, we limited this by a heuristics to only
apply the auto-relative imports for files with a file extension.

Naturally, while testing v4 on more templates, we found that it's common
for people to omit the file extension when loading css file. The above
could also be written as such:

```css
@import 'foo';
```

To improve this, we have two options:

- We either remove the heuristics, making every `@import` more expensive
because we have to check for relative files.
- We upgrade our codemods to rewrite `@import` statements to be
explicitly relative.

Because we really care about performance, we opted to go with the latter
option. This PR adds the codemod and removes the heuristics so we
resolve CSS files similar to how you would resolve JS files.

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
Co-authored-by: Adam Wathan <adam.wathan@gmail.com>
This commit is contained in:
Philipp Spiess 2024-10-22 22:30:41 +02:00 • committed by GitHub
parent c6572ab929
commit a06245b3fe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 154 additions and 17 deletions

View file

@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- _Upgrade (experimental)_: Migrate `plugins` with options to CSS ([#14700](https://github.com/tailwindlabs/tailwindcss/pull/14700))
- _Upgrade (experimental)_: Allow JS configuration files with `corePlugins` options to be migrated to CSS ([#14742](https://github.com/tailwindlabs/tailwindcss/pull/14742))
- _Upgrade (experimental)_: Migrate `@import` statements for relative CSS files to use relative path syntax (e.g. `./file.css`) ([#14755](https://github.com/tailwindlabs/tailwindcss/pull/14755))
- _Upgrade (experimental)_: Migrate `max-w-screen-*` utilities to `max-w-[var(…)]`([#14754](https://github.com/tailwindlabs/tailwindcss/pull/14754))
- _Upgrade (experimental)_: Migrate `@variants` and `@responsive` directives ([#14748](https://github.com/tailwindlabs/tailwindcss/pull/14748))
- _Upgrade (experimental)_: Migrate `@screen` directive ([#14749](https://github.com/tailwindlabs/tailwindcss/pull/14749))
@ -36,6 +37,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Require a relative path prefix for importing relative CSS files (e.g. `@import './styles.css'` instead of `@import 'styles.css'`) ([#14755](https://github.com/tailwindlabs/tailwindcss/pull/14755))
- _Upgrade (experimental)_: Don't create `@source` rules for `content` paths that are already covered by automatic source detection ([#14714](https://github.com/tailwindlabs/tailwindcss/pull/14714))
## [4.0.0-alpha.28] - 2024-10-17

View file

@ -2,7 +2,7 @@ import EnhancedResolve from 'enhanced-resolve'
import { createJiti, type Jiti } from 'jiti'
import fs from 'node:fs'
import fsPromises from 'node:fs/promises'
import path, { dirname, extname } from 'node:path'
import path, { dirname } from 'node:path'
import { pathToFileURL } from 'node:url'
import {
__unstable__loadDesignSystem as ___unstable__loadDesignSystem,
@ -120,21 +120,6 @@ async function resolveCssId(id: string, base: string): Promise<string | false |
}
}
// CSS imports that do not have a dir prefix are considered relative. Since
// the resolver does not account for this, we need to do a first pass with an
// assumed relative import by prefixing `./${path}`. We don't have to do this
// when the path starts with a `.` or when the path has no extension (at which
// case it's likely an npm package and not a relative stylesheet).
let skipRelativeCheck = extname(id) === '' || id.startsWith('.')
if (!skipRelativeCheck) {
try {
let dotResolved = await runResolver(cssResolver, `./${id}`, base)
if (!dotResolved) throw new Error()
return dotResolved
} catch {}
}
return runResolver(cssResolver, id, base)
}

View file

@ -0,0 +1,3 @@
.foo {
color: red;
}

View file

@ -0,0 +1,102 @@
import { __unstable__loadDesignSystem } from '@tailwindcss/node'
import dedent from 'dedent'
import postcss from 'postcss'
import { expect, it } from 'vitest'
import type { UserConfig } from '../../../tailwindcss/src/compat/config/types'
import { migrateImport } from './migrate-import'
const css = dedent
async function migrate(input: string, userConfig: UserConfig = {}) {
return postcss()
.use(
migrateImport({
designSystem: await __unstable__loadDesignSystem(`@import 'tailwindcss';`, {
base: __dirname,
}),
userConfig,
}),
)
.process(input, { from: expect.getState().testPath })
.then((result) => result.css)
}
it('prints relative file imports as relative paths', async () => {
expect(
await migrate(css`
@import 'fixtures/test';
@import 'fixtures/test.css';
@import './fixtures/test.css';
@import './fixtures/test';
@import 'fixtures/test' screen;
@import 'fixtures/test.css' screen;
@import './fixtures/test.css' screen;
@import './fixtures/test' screen;
@import 'fixtures/test' supports(display: grid);
@import 'fixtures/test.css' supports(display: grid);
@import './fixtures/test.css' supports(display: grid);
@import './fixtures/test' supports(display: grid);
@import 'fixtures/test' layer(utilities);
@import 'fixtures/test.css' layer(utilities);
@import './fixtures/test.css' layer(utilities);
@import './fixtures/test' layer(utilities);
@import 'fixtures/test' theme(inline);
@import 'fixtures/test.css' theme(inline);
@import './fixtures/test.css' theme(inline);
@import './fixtures/test' theme(inline);
@import 'fixtures/test' layer(utilities) supports(display: grid) screen and (min-width: 600px);
@import 'fixtures/test.css' layer(utilities) supports(display: grid) screen and
(min-width: 600px);
@import './fixtures/test.css' layer(utilities) supports(display: grid) screen and
(min-width: 600px);
@import './fixtures/test' layer(utilities) supports(display: grid) screen and
(min-width: 600px);
@import 'tailwindcss';
@import 'tailwindcss/theme.css';
@import 'tailwindcss/theme';
`),
).toMatchInlineSnapshot(`
"@import './fixtures/test.css';
@import './fixtures/test.css';
@import './fixtures/test.css';
@import './fixtures/test.css';
@import './fixtures/test.css' screen;
@import './fixtures/test.css' screen;
@import './fixtures/test.css' screen;
@import './fixtures/test.css' screen;
@import './fixtures/test.css' supports(display: grid);
@import './fixtures/test.css' supports(display: grid);
@import './fixtures/test.css' supports(display: grid);
@import './fixtures/test.css' supports(display: grid);
@import './fixtures/test.css' layer(utilities);
@import './fixtures/test.css' layer(utilities);
@import './fixtures/test.css' layer(utilities);
@import './fixtures/test.css' layer(utilities);
@import './fixtures/test.css' theme(inline);
@import './fixtures/test.css' theme(inline);
@import './fixtures/test.css' theme(inline);
@import './fixtures/test.css' theme(inline);
@import './fixtures/test.css' layer(utilities) supports(display: grid) screen and (min-width: 600px);
@import './fixtures/test.css' layer(utilities) supports(display: grid) screen and
(min-width: 600px);
@import './fixtures/test.css' layer(utilities) supports(display: grid) screen and
(min-width: 600px);
@import './fixtures/test.css' layer(utilities) supports(display: grid) screen and
(min-width: 600px);
@import 'tailwindcss';
@import 'tailwindcss/theme.css';
@import 'tailwindcss/theme';"
`)
})

View file

@ -0,0 +1,45 @@
import fs from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { type Plugin, type Root } from 'postcss'
import { parseImportParams } from '../../../tailwindcss/src/at-import'
import { segment } from '../../../tailwindcss/src/utils/segment'
import * as ValueParser from '../../../tailwindcss/src/value-parser'
export function migrateImport(): Plugin {
async function migrate(root: Root) {
let file = root.source?.input.file
if (!file) return
let promises: Promise<void>[] = []
root.walkAtRules('import', (rule) => {
let [firstParam, ...rest] = segment(rule.params, ' ')
let params = parseImportParams(ValueParser.parse(firstParam))
let isRelative = params.uri[0] === '.'
let hasCssExtension = params.uri.endsWith('.css')
if (isRelative && hasCssExtension) {
return
}
let fullPath = resolve(dirname(file), params.uri)
if (!hasCssExtension) fullPath += '.css'
promises.push(
fs.stat(fullPath).then(() => {
let ext = hasCssExtension ? '' : '.css'
let path = isRelative ? params.uri : `./${params.uri}`
rule.params = [`'${path}${ext}'`, ...rest].join(' ')
}),
)
})
await Promise.allSettled(promises)
}
return {
postcssPlugin: '@tailwindcss/upgrade/migrate-import',
OnceExit: migrate,
}
}

View file

@ -67,7 +67,7 @@ export async function substituteAtImports(
// `postcss-import` <https://github.com/postcss/postcss-import>
// Copyright (c) 2014 Maxime Thirouin, Jason Campbell & Kevin Mårtensson
// Released under the MIT License.
function parseImportParams(params: ValueParser.ValueAstNode[]) {
export function parseImportParams(params: ValueParser.ValueAstNode[]) {
let uri
let layer: string | null = null
let media: string | null = null