Add PostCSS plugin to fix relative @content and @plugin paths in @imported files (#14063)

We noticed an issue that happened when handling relative file imports in
the `@plugin` and the upcoming `@content` APIs. The problem arises from
relative files that are inside `@import`ed stylesheets. Take, for
example, the following folder structure:

```css
/* src/index.css */
@import "./dir/index.css";
```
```css
/* src/dir/index.css */
@plugin "../../plugin.ts";
```

It's expected that the path is relative to the CSS file that defined it.
However, right now, we use
[`postcss-import`](https://github.com/postcss/postcss-import) to flatten
the CSS file before running the tailwind build step. This causes these
custom-properties to be inlined in a flat file which removes the
information of which file is being referred:

```css
/* src/flat.css */
@plugin "../../plugin.ts"; /* <- This is now pointing to the wrong file */
```

There are generally two approaches that we can do to solve this:

1. **Handle `@import` flattening inside tailwindcss:** While generally
this would give us more freedom and less dependencies, this would
require some work to get all edge cases right. We need to support
layers/conditional imports and also handle all relative urls for
properties like `background-image`.
2. **Rewrite relative paths as a separate postcss visitor:** The
approach this PR takes is instead to implement a custom postcss plugin
that uses the AST to rewrite relative references inside `@plugin` and
`@content`. This has the benefit of requiring little changes to our
existing APIs. The rule is only enabled for relative references inside
`@plugin` and `@content`, so the surface of this rule is very small.

We can use this plugin inside all three current clients:

- `@tailwindcss/postcss` obviously already uses postcss
- `@tailwindcss/cli` also uses postcss to handle `@import` flattening
- `@tailwindcss/vite` allows us to add custom postcss rules via the CSS
pipeline. There are a few cases that we handle with care (e.g. in vite
you can pass a string to the postcss config which is supposed to load
the config from a file).

To validate the changes, we have added both a list of unit test cases to
the plugin itself as well as verified that all three clients are working
as expected:

- `@tailwindcss/postcss` now has an explicit test for this behavior
- `@tailwindcss/cli` and `@tailwindcss/vite` were manually tested by
updating the vite playground. The CLI was run with `--cwd
playgrounds/vite/ -i ./src/app.css -o foo.css`:
<img width="531" alt="Screenshot 2024-07-29 at 11 35 59"
src="https://github.com/user-attachments/assets/78f0acdc-a46c-4c6c-917a-2916417b1001">
This commit is contained in:
Philipp Spiess 2024-07-29 17:57:50 +02:00
parent 744e43fc0e
commit b07832772a
22 changed files with 450 additions and 99 deletions

View file

@ -12,7 +12,7 @@
"homepage": "https://tailwindcss.com",
"scripts": {
"lint": "tsc --noEmit",
"build": "tsup-node ./src/index.ts --format esm --minify --clean",
"build": "tsup-node",
"dev": "pnpm run build -- --watch"
},
"bin": {
@ -36,7 +36,8 @@
"picocolors": "^1.0.1",
"postcss": "8.4.24",
"postcss-import": "^16.1.0",
"tailwindcss": "workspace:^"
"tailwindcss": "workspace:^",
"internal-postcss-fix-relative-paths": "workspace:^"
},
"devDependencies": {
"@types/postcss-import": "^14.0.3"

View file

@ -1,5 +1,6 @@
import watcher from '@parcel/watcher'
import { IO, Parsing, scanDir, scanFiles, type ChangedContent } from '@tailwindcss/oxide'
import fixRelativePathsPlugin from 'internal-postcss-fix-relative-paths'
import { Features, transform } from 'lightningcss'
import { existsSync } from 'node:fs'
import fs from 'node:fs/promises'
@ -259,6 +260,7 @@ function handleImports(
return postcss()
.use(atImport())
.use(fixRelativePathsPlugin())
.process(input, { from: file })
.then((result) => [
result.css,

View file

@ -0,0 +1,9 @@
import { defineConfig } from 'tsup'
export default defineConfig({
format: ['esm'],
clean: true,
minify: true,
entry: ['src/index.ts'],
noExternal: ['internal-postcss-fix-relative-paths'],
})

View file

@ -12,7 +12,7 @@
"homepage": "https://tailwindcss.com",
"scripts": {
"lint": "tsc --noEmit",
"build": "tsup-node ./src/index.ts --format cjs,esm --dts --cjsInterop --splitting --minify --clean",
"build": "tsup-node",
"dev": "pnpm run build -- --watch"
},
"files": [
@ -33,7 +33,8 @@
"@tailwindcss/oxide": "workspace:^",
"lightningcss": "^1.25.1",
"postcss-import": "^16.1.0",
"tailwindcss": "workspace:^"
"tailwindcss": "workspace:^",
"internal-postcss-fix-relative-paths": "workspace:^"
},
"devDependencies": {
"@types/node": "^20.12.12",

View file

@ -0,0 +1 @@
@plugin '../plugin.js';

View file

@ -144,7 +144,7 @@ describe('plugins', () => {
let result = await processor.process(
css`
@import 'tailwindcss/utilities';
@plugin 'internal-example-plugin';
@plugin './plugin.js';
`,
{ from: INPUT_CSS_PATH },
)
@ -166,6 +166,36 @@ describe('plugins', () => {
`)
})
test('local CJS plugin from `@import`-ed file', async () => {
let processor = postcss([
tailwindcss({ base: `${__dirname}/fixtures/example-project`, optimize: { minify: false } }),
])
let result = await processor.process(
css`
@import 'tailwindcss/utilities';
@import '../example-project/src/relative-import.css';
`,
{ from: `${__dirname}/fixtures/another-project/input.css` },
)
expect(result.css.trim()).toMatchInlineSnapshot(`
".underline {
text-decoration-line: underline;
}
@media (inverted-colors: inverted) {
.inverted\\:flex {
display: flex;
}
}
.hocus\\:underline:focus, .hocus\\:underline:hover {
text-decoration-line: underline;
}"
`)
})
test('published CJS plugin', async () => {
let processor = postcss([
tailwindcss({ base: `${__dirname}/fixtures/example-project`, optimize: { minify: false } }),

View file

@ -2,9 +2,10 @@ import { scanDir } from '@tailwindcss/oxide'
import fs from 'fs'
import { Features, transform } from 'lightningcss'
import path from 'path'
import postcss, { type AcceptedPlugin, type PluginCreator } from 'postcss'
import postcss, { AtRule, type AcceptedPlugin, type PluginCreator } from 'postcss'
import postcssImport from 'postcss-import'
import { compile } from 'tailwindcss'
import fixRelativePathsPlugin from '../../internal-postcss-fix-relative-paths/src'
/**
* A Map that can generate default values for keys that don't exist.
@ -48,114 +49,118 @@ function tailwindcss(opts: PluginOptions = {}): AcceptedPlugin {
}
})
let hasApply: boolean, hasTailwind: boolean
return {
postcssPlugin: '@tailwindcss/postcss',
plugins: [
// We need to run `postcss-import` first to handle `@import` rules.
postcssImport(),
fixRelativePathsPlugin(),
(root, result) => {
let inputFile = result.opts.from ?? ''
let context = cache.get(inputFile)
let rebuildStrategy: 'full' | 'incremental' = 'incremental'
// Track file modification times to CSS files
{
let files = result.messages.flatMap((message) => {
if (message.type !== 'dependency') return []
return message.file
})
files.push(inputFile)
for (let file of files) {
let changedTime = fs.statSync(file, { throwIfNoEntry: false })?.mtimeMs ?? null
if (changedTime === null) {
if (file === inputFile) {
rebuildStrategy = 'full'
}
continue
}
let prevTime = context.mtimes.get(file)
if (prevTime === changedTime) continue
rebuildStrategy = 'full'
context.mtimes.set(file, changedTime)
}
}
let hasApply = false
let hasTailwind = false
root.walkAtRules((rule) => {
{
postcssPlugin: 'tailwindcss',
Once() {
// Reset some state between builds
hasApply = false
hasTailwind = false
},
AtRule(rule: AtRule) {
if (rule.name === 'apply') {
hasApply = true
} else if (rule.name === 'tailwind') {
hasApply = true
hasTailwind = true
// If we've found `@tailwind` then we already
// know we have to run a "full" build
return false
}
})
},
OnceExit(root, { result }) {
let inputFile = result.opts.from ?? ''
let context = cache.get(inputFile)
// Do nothing if neither `@tailwind` nor `@apply` is used
if (!hasTailwind && !hasApply) return
let rebuildStrategy: 'full' | 'incremental' = 'incremental'
let css = ''
// Look for candidates used to generate the CSS
let { candidates, files, globs } = scanDir({ base, globs: true })
// Add all found files as direct dependencies
for (let file of files) {
result.messages.push({
type: 'dependency',
plugin: '@tailwindcss/postcss',
file,
parent: result.opts.from,
})
}
// Register dependencies so changes in `base` cause a rebuild while
// giving tools like Vite or Parcel a glob that can be used to limit
// the files that cause a rebuild to only those that match it.
for (let { base, glob } of globs) {
result.messages.push({
type: 'dir-dependency',
plugin: '@tailwindcss/postcss',
dir: base,
glob,
parent: result.opts.from,
})
}
if (rebuildStrategy === 'full') {
let basePath = path.dirname(path.resolve(inputFile))
let { build } = compile(root.toString(), {
loadPlugin: (pluginPath) => {
if (pluginPath[0] === '.') {
return require(path.resolve(basePath, pluginPath))
// Track file modification times to CSS files
{
let files = result.messages.flatMap((message) => {
if (message.type !== 'dependency') return []
return message.file
})
files.push(inputFile)
for (let file of files) {
let changedTime = fs.statSync(file, { throwIfNoEntry: false })?.mtimeMs ?? null
if (changedTime === null) {
if (file === inputFile) {
rebuildStrategy = 'full'
}
continue
}
return require(pluginPath)
},
})
context.build = build
css = build(hasTailwind ? candidates : [])
} else if (rebuildStrategy === 'incremental') {
css = context.build!(candidates)
}
let prevTime = context.mtimes.get(file)
if (prevTime === changedTime) continue
// Replace CSS
if (css !== context.css && optimize) {
context.optimizedCss = optimizeCss(css, {
minify: typeof optimize === 'object' ? optimize.minify : true,
})
}
context.css = css
root.removeAll()
root.append(postcss.parse(optimize ? context.optimizedCss : context.css, result.opts))
rebuildStrategy = 'full'
context.mtimes.set(file, changedTime)
}
}
// Do nothing if neither `@tailwind` nor `@apply` is used
if (!hasTailwind && !hasApply) return
let css = ''
// Look for candidates used to generate the CSS
let { candidates, files, globs } = scanDir({ base, globs: true })
// Add all found files as direct dependencies
for (let file of files) {
result.messages.push({
type: 'dependency',
plugin: '@tailwindcss/postcss',
file,
parent: result.opts.from,
})
}
// Register dependencies so changes in `base` cause a rebuild while
// giving tools like Vite or Parcel a glob that can be used to limit
// the files that cause a rebuild to only those that match it.
for (let { base, glob } of globs) {
result.messages.push({
type: 'dir-dependency',
plugin: '@tailwindcss/postcss',
dir: base,
glob,
parent: result.opts.from,
})
}
if (rebuildStrategy === 'full') {
let basePath = path.dirname(path.resolve(inputFile))
let { build } = compile(root.toString(), {
loadPlugin: (pluginPath) => {
if (pluginPath[0] === '.') {
return require(path.resolve(basePath, pluginPath))
}
return require(pluginPath)
},
})
context.build = build
css = build(hasTailwind ? candidates : [])
} else if (rebuildStrategy === 'incremental') {
css = context.build!(candidates)
}
// Replace CSS
if (css !== context.css && optimize) {
context.optimizedCss = optimizeCss(css, {
minify: typeof optimize === 'object' ? optimize.minify : true,
})
}
context.css = css
root.removeAll()
root.append(postcss.parse(optimize ? context.optimizedCss : context.css, result.opts))
},
},
],
}

View file

@ -0,0 +1,12 @@
import { defineConfig } from 'tsup'
export default defineConfig({
format: ['esm', 'cjs'],
clean: true,
minify: true,
splitting: true,
cjsInterop: true,
dts: true,
entry: ['src/index.ts'],
noExternal: ['internal-postcss-fix-relative-paths'],
})

View file

@ -11,7 +11,7 @@
"bugs": "https://github.com/tailwindlabs/tailwindcss/issues",
"homepage": "https://tailwindcss.com",
"scripts": {
"build": "tsup-node ./src/index.ts --format esm --dts --minify --clean",
"build": "tsup-node",
"dev": "pnpm run build -- --watch"
},
"files": [
@ -30,10 +30,12 @@
"dependencies": {
"@tailwindcss/oxide": "workspace:^",
"lightningcss": "^1.25.1",
"postcss-load-config": "^6.0.1",
"tailwindcss": "workspace:^"
},
"devDependencies": {
"@types/node": "^20.12.12",
"internal-postcss-fix-relative-paths": "workspace:^",
"vite": "^5.2.11"
},
"peerDependencies": {

View file

@ -1,6 +1,8 @@
import { IO, Parsing, scanFiles } from '@tailwindcss/oxide'
import fixRelativePathsPlugin from 'internal-postcss-fix-relative-paths'
import { Features, transform } from 'lightningcss'
import path from 'path'
import postcssrc from 'postcss-load-config'
import { compile } from 'tailwindcss'
import type { Plugin, Rollup, Update, ViteDevServer } from 'vite'
@ -101,7 +103,7 @@ export default function tailwindcss(): Plugin[] {
for (let plugin of cssPlugins) {
if (!plugin.transform) continue
const transformHandler =
let transformHandler =
'handler' in plugin.transform! ? plugin.transform.handler : plugin.transform!
try {
@ -152,6 +154,55 @@ export default function tailwindcss(): Plugin[] {
})
},
// Append the postcss-fix-relative-paths plugin
async config(config) {
let postcssConfig = config.css?.postcss
if (typeof postcssConfig === 'string') {
// We expand string configs to their PostCSS config object similar to
// how Vite does it.
// See: https://github.com/vitejs/vite/blob/440783953a55c6c63cd09ec8d13728dc4693073d/packages/vite/src/node/plugins/css.ts#L1580
let searchPath = typeof postcssConfig === 'string' ? postcssConfig : config.root
let parsedConfig = await postcssrc({}, searchPath).catch((e: Error) => {
if (!e.message.includes('No PostCSS Config found')) {
if (e instanceof Error) {
let { name, message, stack } = e
e.name = 'Failed to load PostCSS config'
e.message = `Failed to load PostCSS config (searchPath: ${searchPath}): [${name}] ${message}\n${stack}`
e.stack = '' // add stack to message to retain stack
throw e
} else {
throw new Error(`Failed to load PostCSS config: ${e}`)
}
}
return null
})
if (parsedConfig !== null) {
postcssConfig = {
options: parsedConfig.options,
plugins: parsedConfig.plugins,
} as any
} else {
postcssConfig = {}
}
config.css = { postcss: postcssConfig }
}
// postcssConfig is no longer a string after the above. This test is to
// avoid TypeScript errors below.
if (typeof postcssConfig === 'string') {
return
}
if (!postcssConfig || !postcssConfig?.plugins) {
config.css = config.css || {}
config.css.postcss = postcssConfig || {}
config.css.postcss.plugins = [fixRelativePathsPlugin() as any]
} else {
postcssConfig.plugins.push(fixRelativePathsPlugin() as any)
}
},
// Scan index.html for candidates
transformIndexHtml(html) {
let updated = scan(html, 'html')

View file

@ -0,0 +1,10 @@
import { defineConfig } from 'tsup'
export default defineConfig({
format: ['esm'],
clean: true,
minify: true,
dts: true,
entry: ['src/index.ts'],
noExternal: ['internal-postcss-fix-relative-paths'],
})

View file

@ -0,0 +1,27 @@
{
"name": "internal-postcss-fix-relative-paths",
"version": "0.0.0",
"private": true,
"scripts": {
"lint": "tsc --noEmit",
"build": "tsup-node ./src/index.ts --format cjs,esm --dts --cjsInterop --splitting --minify --clean",
"dev": "pnpm run build -- --watch"
},
"files": [
"dist/"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
},
"dependencies": {},
"devDependencies": {
"@types/node": "^20.12.12",
"@types/postcss-import": "^14.0.3",
"postcss": "8.4.24",
"postcss-import": "^16.1.0"
}
}

View file

@ -0,0 +1,3 @@
@content "./**/*.ts";
@plugin "./plugin.js";
@plugin "./what\"s-this.js";

View file

@ -0,0 +1,4 @@
@plugin "/absolute/paths";
@plugin "C:\Program Files\HAL 9000";
@plugin "\\Media\Pictures\Worth\1000 words";
@plugin "some-node-dep";

View file

@ -0,0 +1 @@
@import '../../example-project/src/index.css';

View file

@ -0,0 +1 @@
@import '../../example-project/src/invalid.css';

View file

@ -0,0 +1,5 @@
@import './plugins-in-sibling.css';
@plugin './plugin-in-root.ts';
@plugin '../plugin-in-root.ts';
@plugin 'plugin-in-root';

View file

@ -0,0 +1,3 @@
@plugin './plugin-in-sibling.ts';
@plugin '../plugin-in-sibling.ts';
@plugin 'plugin-in-sibling';

View file

@ -0,0 +1,56 @@
import fs from 'node:fs'
import postcss from 'postcss'
import atImport from 'postcss-import'
import { describe, expect, test } from 'vitest'
import fixRelativePathsPlugin from '.'
describe('fixRelativePathsPlugin', () => {
test('rewrites @content and @plugin to be relative to the initial css file', async () => {
let cssPath = `${__dirname}/fixtures/external-import/src/index.css`
let css = fs.readFileSync(cssPath, 'utf-8')
let processor = postcss([atImport(), fixRelativePathsPlugin()])
let result = await processor.process(css, { from: cssPath })
expect(result.css.trim()).toMatchInlineSnapshot(`
"@content "../../example-project/src/**/*.ts";
@plugin "../../example-project/src/plugin.js";
@plugin "../../example-project/src/what\\"s-this.js";"
`)
})
test('should not rewrite non-relative paths', async () => {
let cssPath = `${__dirname}/fixtures/external-import/src/invalid.css`
let css = fs.readFileSync(cssPath, 'utf-8')
let processor = postcss([atImport(), fixRelativePathsPlugin()])
let result = await processor.process(css, { from: cssPath })
expect(result.css.trim()).toMatchInlineSnapshot(`
"@plugin "/absolute/paths";
@plugin "C:\\Program Files\\HAL 9000";
@plugin "\\\\Media\\Pictures\\Worth\\1000 words";
@plugin "some-node-dep";"
`)
})
test('should return relative paths even if the file is resolved in the same basedir as the root stylesheet', async () => {
let cssPath = `${__dirname}/fixtures/external-import/src/plugins-in-root.css`
let css = fs.readFileSync(cssPath, 'utf-8')
let processor = postcss([atImport(), fixRelativePathsPlugin()])
let result = await processor.process(css, { from: cssPath })
expect(result.css.trim()).toMatchInlineSnapshot(`
"@plugin './plugin-in-sibling.ts';
@plugin '../plugin-in-sibling.ts';
@plugin 'plugin-in-sibling';
@plugin './plugin-in-root.ts';
@plugin '../plugin-in-root.ts';
@plugin 'plugin-in-root';"
`)
})
})

View file

@ -0,0 +1,72 @@
import path from 'node:path'
import type { AtRule, Container, Plugin } from 'postcss'
const SINGLE_QUOTE = "'"
const DOUBLE_QUOTE = '"'
export default function fixRelativePathsPlugin(): Plugin {
// Retain a list of touched at-rules to avoid infinite loops
let touched: WeakSet<AtRule> = new WeakSet()
function fixRelativePath(atRule: AtRule) {
let rootPath = getRoot(atRule)?.source?.input.file
if (!rootPath) {
return
}
let inputFilePath = atRule?.source?.input.file
if (!inputFilePath) {
return
}
if (touched.has(atRule)) {
return
}
let value = atRule.params[0]
let quote =
value[0] === DOUBLE_QUOTE && value[value.length - 1] === DOUBLE_QUOTE
? DOUBLE_QUOTE
: value[0] === SINGLE_QUOTE && value[value.length - 1] === SINGLE_QUOTE
? SINGLE_QUOTE
: null
if (!quote) {
return
}
let content = atRule.params.slice(1, -1)
// We only want to rewrite relative paths.
if (!content.startsWith('./') && !content.startsWith('../')) {
return
}
let rulePath = path.posix.join(path.posix.dirname(inputFilePath), content)
let relative = path.posix.relative(path.posix.dirname(rootPath), rulePath)
// If the path points to a file in the same directory, `path.relative` will
// remove the leading `./` and we need to add it back in order to still
// consider the path relative
if (!relative.startsWith('.')) {
relative = './' + relative
}
atRule.params = quote + relative + quote
touched.add(atRule)
}
function getRoot(node: AtRule | Container | undefined): Container | undefined {
if (node?.parent) {
return getRoot(node.parent as Container)
}
return node
}
return {
postcssPlugin: 'tailwindcss-postcss-fix-relative-paths',
AtRule: {
content: fixRelativePath,
plugin: fixRelativePath,
},
}
}

View file

@ -0,0 +1,3 @@
{
"extends": "../tsconfig.base.json",
}

52
pnpm-lock.yaml generated
View file

@ -109,6 +109,9 @@ importers:
'@tailwindcss/oxide':
specifier: workspace:^
version: link:../../crates/node
internal-postcss-fix-relative-paths:
specifier: workspace:^
version: link:../internal-postcss-fix-relative-paths
lightningcss:
specifier: ^1.25.1
version: 1.25.1
@ -137,6 +140,9 @@ importers:
'@tailwindcss/oxide':
specifier: workspace:^
version: link:../../crates/node
internal-postcss-fix-relative-paths:
specifier: workspace:^
version: link:../internal-postcss-fix-relative-paths
lightningcss:
specifier: ^1.25.1
version: 1.25.1
@ -168,6 +174,9 @@ importers:
lightningcss:
specifier: ^1.25.1
version: 1.25.1
postcss-load-config:
specifier: ^6.0.1
version: 6.0.1(postcss@8.4.38)(yaml@2.4.2)
tailwindcss:
specifier: workspace:^
version: link:../tailwindcss
@ -175,12 +184,30 @@ importers:
'@types/node':
specifier: ^20.12.12
version: 20.12.12
internal-postcss-fix-relative-paths:
specifier: workspace:^
version: link:../internal-postcss-fix-relative-paths
vite:
specifier: ^5.2.11
version: 5.2.11(@types/node@20.12.12)(lightningcss@1.25.1)
packages/internal-example-plugin: {}
packages/internal-postcss-fix-relative-paths:
devDependencies:
'@types/node':
specifier: ^20.12.12
version: 20.12.12
'@types/postcss-import':
specifier: ^14.0.3
version: 14.0.3
postcss:
specifier: 8.4.24
version: 8.4.24
postcss-import:
specifier: ^16.1.0
version: 16.1.0(postcss@8.4.24)
packages/tailwindcss:
devDependencies:
'@tailwindcss/oxide':
@ -2338,6 +2365,24 @@ packages:
ts-node:
optional: true
postcss-load-config@6.0.1:
resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==}
engines: {node: '>= 18'}
peerDependencies:
jiti: '>=1.21.0'
postcss: '>=8.0.9'
tsx: ^4.8.1
yaml: ^2.4.2
peerDependenciesMeta:
jiti:
optional: true
postcss:
optional: true
tsx:
optional: true
yaml:
optional: true
postcss-value-parser@4.2.0:
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
@ -5034,6 +5079,13 @@ snapshots:
optionalDependencies:
postcss: 8.4.24
postcss-load-config@6.0.1(postcss@8.4.38)(yaml@2.4.2):
dependencies:
lilconfig: 3.1.1
optionalDependencies:
postcss: 8.4.38
yaml: 2.4.2
postcss-value-parser@4.2.0: {}
postcss@8.4.24: