Skip to content

Commit 6d39204

Browse files
philipp-spiessRobinMalfait
authored andcommitted
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">
1 parent fdb3941 commit 6d39204

File tree

22 files changed

+452
-101
lines changed

22 files changed

+452
-101
lines changed

packages/@tailwindcss-cli/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
"homepage": "https://tailwindcss.com",
1313
"scripts": {
1414
"lint": "tsc --noEmit",
15-
"build": "tsup-node ./src/index.ts --format esm --minify --clean",
15+
"build": "tsup-node",
1616
"dev": "pnpm run build -- --watch"
1717
},
1818
"bin": {
@@ -36,7 +36,8 @@
3636
"picocolors": "^1.0.1",
3737
"postcss": "8.4.24",
3838
"postcss-import": "^16.1.0",
39-
"tailwindcss": "workspace:^"
39+
"tailwindcss": "workspace:^",
40+
"internal-postcss-fix-relative-paths": "workspace:^"
4041
},
4142
"devDependencies": {
4243
"@types/postcss-import": "^14.0.3"

packages/@tailwindcss-cli/src/commands/build/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import watcher from '@parcel/watcher'
22
import { IO, Parsing, scanDir, scanFiles, type ChangedContent } from '@tailwindcss/oxide'
3+
import fixRelativePathsPlugin from 'internal-postcss-fix-relative-paths'
34
import { Features, transform } from 'lightningcss'
45
import { existsSync } from 'node:fs'
56
import fs from 'node:fs/promises'
@@ -259,6 +260,7 @@ function handleImports(
259260

260261
return postcss()
261262
.use(atImport())
263+
.use(fixRelativePathsPlugin())
262264
.process(input, { from: file })
263265
.then((result) => [
264266
result.css,
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { defineConfig } from 'tsup'
2+
3+
export default defineConfig({
4+
format: ['esm'],
5+
clean: true,
6+
minify: true,
7+
entry: ['src/index.ts'],
8+
noExternal: ['internal-postcss-fix-relative-paths'],
9+
})

packages/@tailwindcss-postcss/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
"homepage": "https://tailwindcss.com",
1313
"scripts": {
1414
"lint": "tsc --noEmit",
15-
"build": "tsup-node ./src/index.ts --format cjs,esm --dts --cjsInterop --splitting --minify --clean",
15+
"build": "tsup-node",
1616
"dev": "pnpm run build -- --watch"
1717
},
1818
"files": [
@@ -33,7 +33,8 @@
3333
"@tailwindcss/oxide": "workspace:^",
3434
"lightningcss": "^1.25.1",
3535
"postcss-import": "^16.1.0",
36-
"tailwindcss": "workspace:^"
36+
"tailwindcss": "workspace:^",
37+
"internal-postcss-fix-relative-paths": "workspace:^"
3738
},
3839
"devDependencies": {
3940
"@types/node": "^20.12.12",
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
@plugin '../plugin.js';

packages/@tailwindcss-postcss/src/index.test.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ describe('plugins', () => {
144144
let result = await processor.process(
145145
css`
146146
@import 'tailwindcss/utilities';
147-
@plugin 'internal-example-plugin';
147+
@plugin './plugin.js';
148148
`,
149149
{ from: INPUT_CSS_PATH },
150150
)
@@ -166,6 +166,36 @@ describe('plugins', () => {
166166
`)
167167
})
168168

169+
test('local CJS plugin from `@import`-ed file', async () => {
170+
let processor = postcss([
171+
tailwindcss({ base: `${__dirname}/fixtures/example-project`, optimize: { minify: false } }),
172+
])
173+
174+
let result = await processor.process(
175+
css`
176+
@import 'tailwindcss/utilities';
177+
@import '../example-project/src/relative-import.css';
178+
`,
179+
{ from: `${__dirname}/fixtures/another-project/input.css` },
180+
)
181+
182+
expect(result.css.trim()).toMatchInlineSnapshot(`
183+
".underline {
184+
text-decoration-line: underline;
185+
}
186+
187+
@media (inverted-colors: inverted) {
188+
.inverted\\:flex {
189+
display: flex;
190+
}
191+
}
192+
193+
.hocus\\:underline:focus, .hocus\\:underline:hover {
194+
text-decoration-line: underline;
195+
}"
196+
`)
197+
})
198+
169199
test('published CJS plugin', async () => {
170200
let processor = postcss([
171201
tailwindcss({ base: `${__dirname}/fixtures/example-project`, optimize: { minify: false } }),

packages/@tailwindcss-postcss/src/index.ts

Lines changed: 99 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ import { scanDir } from '@tailwindcss/oxide'
22
import fs from 'fs'
33
import { Features, transform } from 'lightningcss'
44
import path from 'path'
5-
import postcss, { type AcceptedPlugin, type PluginCreator } from 'postcss'
5+
import postcss, { AtRule, type AcceptedPlugin, type PluginCreator } from 'postcss'
66
import postcssImport from 'postcss-import'
77
import { compile } from 'tailwindcss'
8+
import fixRelativePathsPlugin from '../../internal-postcss-fix-relative-paths/src'
89

910
/**
1011
* A Map that can generate default values for keys that don't exist.
@@ -48,114 +49,118 @@ function tailwindcss(opts: PluginOptions = {}): AcceptedPlugin {
4849
}
4950
})
5051

52+
let hasApply: boolean, hasTailwind: boolean
53+
5154
return {
5255
postcssPlugin: '@tailwindcss/postcss',
5356
plugins: [
5457
// We need to run `postcss-import` first to handle `@import` rules.
5558
postcssImport(),
59+
fixRelativePathsPlugin(),
60+
61+
{
62+
postcssPlugin: 'tailwindcss',
63+
Once() {
64+
// Reset some state between builds
65+
hasApply = false
66+
hasTailwind = false
67+
},
68+
AtRule(rule: AtRule) {
69+
if (rule.name === 'apply') {
70+
hasApply = true
71+
} else if (rule.name === 'tailwind') {
72+
hasApply = true
73+
hasTailwind = true
74+
}
75+
},
76+
OnceExit(root, { result }) {
77+
let inputFile = result.opts.from ?? ''
78+
let context = cache.get(inputFile)
79+
80+
let rebuildStrategy: 'full' | 'incremental' = 'incremental'
81+
82+
// Track file modification times to CSS files
83+
{
84+
let files = result.messages.flatMap((message) => {
85+
if (message.type !== 'dependency') return []
86+
return message.file
87+
})
88+
files.push(inputFile)
89+
for (let file of files) {
90+
let changedTime = fs.statSync(file, { throwIfNoEntry: false })?.mtimeMs ?? null
91+
if (changedTime === null) {
92+
if (file === inputFile) {
93+
rebuildStrategy = 'full'
94+
}
95+
continue
96+
}
5697

57-
(root, result) => {
58-
let inputFile = result.opts.from ?? ''
59-
let context = cache.get(inputFile)
60-
61-
let rebuildStrategy: 'full' | 'incremental' = 'incremental'
98+
let prevTime = context.mtimes.get(file)
99+
if (prevTime === changedTime) continue
62100

63-
// Track file modification times to CSS files
64-
{
65-
let files = result.messages.flatMap((message) => {
66-
if (message.type !== 'dependency') return []
67-
return message.file
68-
})
69-
files.push(inputFile)
70-
for (let file of files) {
71-
let changedTime = fs.statSync(file, { throwIfNoEntry: false })?.mtimeMs ?? null
72-
if (changedTime === null) {
73-
if (file === inputFile) {
74-
rebuildStrategy = 'full'
75-
}
76-
continue
101+
rebuildStrategy = 'full'
102+
context.mtimes.set(file, changedTime)
77103
}
104+
}
78105

79-
let prevTime = context.mtimes.get(file)
80-
if (prevTime === changedTime) continue
106+
// Do nothing if neither `@tailwind` nor `@apply` is used
107+
if (!hasTailwind && !hasApply) return
81108

82-
rebuildStrategy = 'full'
83-
context.mtimes.set(file, changedTime)
109+
let css = ''
110+
111+
// Look for candidates used to generate the CSS
112+
let { candidates, files, globs } = scanDir({ base, globs: true })
113+
114+
// Add all found files as direct dependencies
115+
for (let file of files) {
116+
result.messages.push({
117+
type: 'dependency',
118+
plugin: '@tailwindcss/postcss',
119+
file,
120+
parent: result.opts.from,
121+
})
84122
}
85-
}
86123

87-
let hasApply = false
88-
let hasTailwind = false
124+
// Register dependencies so changes in `base` cause a rebuild while
125+
// giving tools like Vite or Parcel a glob that can be used to limit
126+
// the files that cause a rebuild to only those that match it.
127+
for (let { base, glob } of globs) {
128+
result.messages.push({
129+
type: 'dir-dependency',
130+
plugin: '@tailwindcss/postcss',
131+
dir: base,
132+
glob,
133+
parent: result.opts.from,
134+
})
135+
}
89136

90-
root.walkAtRules((rule) => {
91-
if (rule.name === 'apply') {
92-
hasApply = true
93-
} else if (rule.name === 'tailwind') {
94-
hasApply = true
95-
hasTailwind = true
96-
// If we've found `@tailwind` then we already
97-
// know we have to run a "full" build
98-
return false
137+
if (rebuildStrategy === 'full') {
138+
let basePath = path.dirname(path.resolve(inputFile))
139+
let { build } = compile(root.toString(), {
140+
loadPlugin: (pluginPath) => {
141+
if (pluginPath[0] === '.') {
142+
return require(path.resolve(basePath, pluginPath))
143+
}
144+
145+
return require(pluginPath)
146+
},
147+
})
148+
context.build = build
149+
css = build(hasTailwind ? candidates : [])
150+
} else if (rebuildStrategy === 'incremental') {
151+
css = context.build!(candidates)
99152
}
100-
})
101-
102-
// Do nothing if neither `@tailwind` nor `@apply` is used
103-
if (!hasTailwind && !hasApply) return
104-
105-
let css = ''
106-
107-
// Look for candidates used to generate the CSS
108-
let { candidates, files, globs } = scanDir({ base, globs: true })
109-
110-
// Add all found files as direct dependencies
111-
for (let file of files) {
112-
result.messages.push({
113-
type: 'dependency',
114-
plugin: '@tailwindcss/postcss',
115-
file,
116-
parent: result.opts.from,
117-
})
118-
}
119-
120-
// Register dependencies so changes in `base` cause a rebuild while
121-
// giving tools like Vite or Parcel a glob that can be used to limit
122-
// the files that cause a rebuild to only those that match it.
123-
for (let { base, glob } of globs) {
124-
result.messages.push({
125-
type: 'dir-dependency',
126-
plugin: '@tailwindcss/postcss',
127-
dir: base,
128-
glob,
129-
parent: result.opts.from,
130-
})
131-
}
132-
133-
if (rebuildStrategy === 'full') {
134-
let basePath = path.dirname(path.resolve(inputFile))
135-
let { build } = compile(root.toString(), {
136-
loadPlugin: (pluginPath) => {
137-
if (pluginPath[0] === '.') {
138-
return require(path.resolve(basePath, pluginPath))
139-
}
140153

141-
return require(pluginPath)
142-
},
143-
})
144-
context.build = build
145-
css = build(hasTailwind ? candidates : [])
146-
} else if (rebuildStrategy === 'incremental') {
147-
css = context.build!(candidates)
148-
}
149-
150-
// Replace CSS
151-
if (css !== context.css && optimize) {
152-
context.optimizedCss = optimizeCss(css, {
153-
minify: typeof optimize === 'object' ? optimize.minify : true,
154-
})
155-
}
156-
context.css = css
157-
root.removeAll()
158-
root.append(postcss.parse(optimize ? context.optimizedCss : context.css, result.opts))
154+
// Replace CSS
155+
if (css !== context.css && optimize) {
156+
context.optimizedCss = optimizeCss(css, {
157+
minify: typeof optimize === 'object' ? optimize.minify : true,
158+
})
159+
}
160+
context.css = css
161+
root.removeAll()
162+
root.append(postcss.parse(optimize ? context.optimizedCss : context.css, result.opts))
163+
},
159164
},
160165
],
161166
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { defineConfig } from 'tsup'
2+
3+
export default defineConfig({
4+
format: ['esm', 'cjs'],
5+
clean: true,
6+
minify: true,
7+
splitting: true,
8+
cjsInterop: true,
9+
dts: true,
10+
entry: ['src/index.ts'],
11+
noExternal: ['internal-postcss-fix-relative-paths'],
12+
})

packages/@tailwindcss-vite/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"bugs": "https://github.com/tailwindlabs/tailwindcss/issues",
1212
"homepage": "https://tailwindcss.com",
1313
"scripts": {
14-
"build": "tsup-node ./src/index.ts --format esm --dts --minify --clean",
14+
"build": "tsup-node",
1515
"dev": "pnpm run build -- --watch"
1616
},
1717
"files": [
@@ -30,10 +30,12 @@
3030
"dependencies": {
3131
"@tailwindcss/oxide": "workspace:^",
3232
"lightningcss": "^1.25.1",
33+
"postcss-load-config": "^6.0.1",
3334
"tailwindcss": "workspace:^"
3435
},
3536
"devDependencies": {
3637
"@types/node": "^20.12.12",
38+
"internal-postcss-fix-relative-paths": "workspace:^",
3739
"vite": "^5.2.11"
3840
},
3941
"peerDependencies": {

0 commit comments

Comments
 (0)