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

324 lines
10 KiB
TypeScript
Raw Normal View History

2024-03-05 14:23:26 +01:00
import { IO, Parsing, scanFiles } from '@tailwindcss/oxide'
Add PostCSS plugin to fix relative `@content` and `@plugin` paths in `@import`ed 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">
2024-07-29 17:57:50 +02:00
import fixRelativePathsPlugin from 'internal-postcss-fix-relative-paths'
import { Features, transform } from 'lightningcss'
2024-03-05 14:23:26 +01:00
import path from 'path'
Add PostCSS plugin to fix relative `@content` and `@plugin` paths in `@import`ed 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">
2024-07-29 17:57:50 +02:00
import postcssrc from 'postcss-load-config'
import { compile } from 'tailwindcss'
import type { Plugin, Rollup, Update, ViteDevServer } from 'vite'
2024-03-05 14:23:26 +01:00
export default function tailwindcss(): Plugin[] {
let server: ViteDevServer | null = null
let candidates = new Set<string>()
// In serve mode this is treated as a set — the content doesn't matter.
// In build mode, we store file contents to use them in renderChunk.
let cssModules: Record<
string,
{
content: string
handled: boolean
}
> = {}
let isSSR = false
2024-03-05 14:23:26 +01:00
let minify = false
let cssPlugins: readonly Plugin[] = []
2024-03-05 14:23:26 +01:00
// Trigger update to all CSS modules
function updateCssModules(isSSR: boolean) {
2024-03-05 14:23:26 +01:00
// If we're building then we don't need to update anything
if (!server) return
let updates: Update[] = []
for (let id of Object.keys(cssModules)) {
2024-03-05 14:23:26 +01:00
let cssModule = server.moduleGraph.getModuleById(id)
if (!cssModule) {
// Note: Removing this during SSR is not safe and will produce
// inconsistent results based on the timing of the removal and
// the order / timing of transforms.
if (!isSSR) {
// It is safe to remove the item here since we're iterating on a copy
// of the keys.
delete cssModules[id]
}
2024-03-05 14:23:26 +01:00
continue
}
server.moduleGraph.invalidateModule(cssModule)
updates.push({
type: `${cssModule.type}-update`,
path: cssModule.url,
acceptedPath: cssModule.url,
timestamp: Date.now(),
})
}
if (updates.length > 0) {
server.hot.send({ type: 'update', updates })
}
}
function scan(src: string, extension: string) {
let updated = false
// Parse all candidates given the resolved files
for (let candidate of scanFiles(
[{ content: src, extension }],
IO.Sequential | Parsing.Sequential,
)) {
// On an initial or full build, updated becomes true immediately so we
// won't be making extra checks.
if (!updated) {
if (candidates.has(candidate)) continue
updated = true
}
candidates.add(candidate)
}
return updated
}
function generateCss(css: string, inputPath: string) {
let basePath = path.dirname(path.resolve(inputPath))
return compile(css, {
loadPlugin: (pluginPath) => {
if (pluginPath[0] === '.') {
return require(path.resolve(basePath, pluginPath))
}
return require(pluginPath)
},
}).build(Array.from(candidates))
}
function generateOptimizedCss(css: string, inputPath: string) {
return optimizeCss(generateCss(css, inputPath), { minify })
2024-03-05 14:23:26 +01:00
}
// Manually run the transform functions of non-Tailwind plugins on the given CSS
async function transformWithPlugins(context: Rollup.PluginContext, id: string, css: string) {
let transformPluginContext = {
...context,
getCombinedSourcemap: () => {
throw new Error('getCombinedSourcemap not implemented')
},
}
for (let plugin of cssPlugins) {
if (!plugin.transform) continue
Add PostCSS plugin to fix relative `@content` and `@plugin` paths in `@import`ed 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">
2024-07-29 17:57:50 +02:00
let transformHandler =
'handler' in plugin.transform! ? plugin.transform.handler : plugin.transform!
try {
// Directly call the plugin's transform function to process the
// generated CSS. In build mode, this updates the chunks later used to
// generate the bundle. In serve mode, the transformed source should be
// applied in transform.
let result = await transformHandler.call(transformPluginContext, css, id)
if (!result) continue
if (typeof result === 'string') {
css = result
} else if (result.code) {
css = result.code
}
} catch (e) {
console.error(`Error running ${plugin.name} on Tailwind CSS output. Skipping.`)
}
}
return css
}
2024-03-05 14:23:26 +01:00
return [
{
// Step 1: Scan source files for candidates
name: '@tailwindcss/vite:scan',
enforce: 'pre',
configureServer(_server) {
server = _server
},
async configResolved(config) {
minify = config.build.cssMinify !== false
isSSR = config.build.ssr !== false && config.build.ssr !== undefined
let allowedPlugins = [
// Apply the vite:css plugin to generated CSS for transformations like
// URL path rewriting and image inlining.
'vite:css',
// In build mode, since renderChunk runs after all transformations, we
// need to also apply vite:css-post.
...(config.command === 'build' ? ['vite:css-post'] : []),
]
cssPlugins = config.plugins.filter((plugin) => {
return allowedPlugins.includes(plugin.name)
})
2024-03-05 14:23:26 +01:00
},
Add PostCSS plugin to fix relative `@content` and `@plugin` paths in `@import`ed 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">
2024-07-29 17:57:50 +02:00
// 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)
}
},
2024-03-05 14:23:26 +01:00
// Scan index.html for candidates
transformIndexHtml(html) {
let updated = scan(html, 'html')
// In serve mode, if the generated CSS contains a URL that causes the
2024-03-05 14:23:26 +01:00
// browser to load a page (e.g. an URL to a missing image), triggering a
// CSS update will cause an infinite loop. We only trigger if the
// candidates have been updated.
if (updated) {
updateCssModules(isSSR)
2024-03-05 14:23:26 +01:00
}
},
// Scan all non-CSS files for candidates
transform(src, id, options) {
2024-03-05 14:23:26 +01:00
if (id.includes('/.vite/')) return
let extension = getExtension(id)
2024-03-05 14:23:26 +01:00
if (extension === '' || extension === 'css') return
scan(src, extension)
updateCssModules(options?.ssr ?? false)
2024-03-05 14:23:26 +01:00
},
},
/*
* The plugins that generate CSS must run after 'enforce: pre' so @imports
* are expanded in transform.
*/
2024-03-05 14:23:26 +01:00
{
// Step 2 (serve mode): Generate CSS
2024-03-05 14:23:26 +01:00
name: '@tailwindcss/vite:generate:serve',
apply: 'serve',
2024-03-20 16:40:50 -04:00
async transform(src, id, options) {
if (!isTailwindCssFile(id, src)) return
2024-03-05 14:23:26 +01:00
// In serve mode, we treat cssModules as a set, ignoring the value.
cssModules[id] = { content: '', handled: true }
2024-03-05 14:23:26 +01:00
if (!options?.ssr) {
// Wait until all other files have been processed, so we can extract
// all candidates before generating CSS. This must not be called
// during SSR or it will block the server.
await server?.waitForRequestsIdle?.(id)
}
2024-03-05 14:23:26 +01:00
let code = await transformWithPlugins(this, id, generateCss(src, id))
return { code }
2024-03-05 14:23:26 +01:00
},
},
{
// Step 2 (full build): Generate CSS
name: '@tailwindcss/vite:generate:build',
apply: 'build',
transform(src, id) {
if (!isTailwindCssFile(id, src)) return
cssModules[id] = { content: src, handled: false }
},
// renderChunk runs in the bundle generation stage after all transforms.
// We must run before `enforce: post` so the updated chunks are picked up
// by vite:css-post.
async renderChunk(_code, _chunk) {
for (let [id, file] of Object.entries(cssModules)) {
if (file.handled) {
continue
}
let css = generateOptimizedCss(file.content, id)
// These plugins have side effects which, during build, results in CSS
// being written to the output dir. We need to run them here to ensure
// the CSS is written before the bundle is generated.
await transformWithPlugins(this, id, css)
file.handled = true
2024-03-05 14:23:26 +01:00
}
},
},
] satisfies Plugin[]
}
function getExtension(id: string) {
let [filename] = id.split('?', 2)
return path.extname(filename).slice(1)
}
function isTailwindCssFile(id: string, src: string) {
if (id.includes('/.vite/')) return
return getExtension(id) === 'css' && src.includes('@tailwind')
}
function optimizeCss(
input: string,
{ file = 'input.css', minify = false }: { file?: string; minify?: boolean } = {},
) {
return transform({
filename: file,
code: Buffer.from(input),
minify,
sourceMap: false,
drafts: {
customMedia: true,
},
nonStandard: {
deepSelectorCombinator: true,
},
include: Features.Nesting,
exclude: Features.LogicalProperties,
targets: {
safari: (16 << 16) | (4 << 8),
},
errorRecovery: true,
}).code.toString()
}