From b07832772ab82cf6650f7aee3eb065facd115288 Mon Sep 17 00:00:00 2001 From: Philipp Spiess Date: Mon, 29 Jul 2024 17:57:50 +0200 Subject: [PATCH] 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`: Screenshot 2024-07-29 at 11 35 59 --- packages/@tailwindcss-cli/package.json | 5 +- .../src/commands/build/index.ts | 2 + packages/@tailwindcss-cli/tsup.config.ts | 9 + packages/@tailwindcss-postcss/package.json | 5 +- .../example-project/src/relative-import.css | 1 + .../@tailwindcss-postcss/src/index.test.ts | 32 ++- packages/@tailwindcss-postcss/src/index.ts | 189 +++++++++--------- packages/@tailwindcss-postcss/tsup.config.ts | 12 ++ packages/@tailwindcss-vite/package.json | 4 +- packages/@tailwindcss-vite/src/index.ts | 53 ++++- packages/@tailwindcss-vite/tsup.config.ts | 10 + .../package.json | 27 +++ .../fixtures/example-project/src/index.css | 3 + .../fixtures/example-project/src/invalid.css | 4 + .../fixtures/external-import/src/index.css | 1 + .../fixtures/external-import/src/invalid.css | 1 + .../external-import/src/plugins-in-root.css | 5 + .../src/plugins-in-sibling.css | 3 + .../src/index.test.ts | 56 ++++++ .../src/index.ts | 72 +++++++ .../tsconfig.json | 3 + pnpm-lock.yaml | 52 +++++ 22 files changed, 450 insertions(+), 99 deletions(-) create mode 100644 packages/@tailwindcss-cli/tsup.config.ts create mode 100644 packages/@tailwindcss-postcss/src/fixtures/example-project/src/relative-import.css create mode 100644 packages/@tailwindcss-postcss/tsup.config.ts create mode 100644 packages/@tailwindcss-vite/tsup.config.ts create mode 100644 packages/internal-postcss-fix-relative-paths/package.json create mode 100644 packages/internal-postcss-fix-relative-paths/src/fixtures/example-project/src/index.css create mode 100644 packages/internal-postcss-fix-relative-paths/src/fixtures/example-project/src/invalid.css create mode 100644 packages/internal-postcss-fix-relative-paths/src/fixtures/external-import/src/index.css create mode 100644 packages/internal-postcss-fix-relative-paths/src/fixtures/external-import/src/invalid.css create mode 100644 packages/internal-postcss-fix-relative-paths/src/fixtures/external-import/src/plugins-in-root.css create mode 100644 packages/internal-postcss-fix-relative-paths/src/fixtures/external-import/src/plugins-in-sibling.css create mode 100644 packages/internal-postcss-fix-relative-paths/src/index.test.ts create mode 100644 packages/internal-postcss-fix-relative-paths/src/index.ts create mode 100644 packages/internal-postcss-fix-relative-paths/tsconfig.json diff --git a/packages/@tailwindcss-cli/package.json b/packages/@tailwindcss-cli/package.json index 53344e308..d8b3ce970 100644 --- a/packages/@tailwindcss-cli/package.json +++ b/packages/@tailwindcss-cli/package.json @@ -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" diff --git a/packages/@tailwindcss-cli/src/commands/build/index.ts b/packages/@tailwindcss-cli/src/commands/build/index.ts index 62084ec45..07f53fcc4 100644 --- a/packages/@tailwindcss-cli/src/commands/build/index.ts +++ b/packages/@tailwindcss-cli/src/commands/build/index.ts @@ -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, diff --git a/packages/@tailwindcss-cli/tsup.config.ts b/packages/@tailwindcss-cli/tsup.config.ts new file mode 100644 index 000000000..236281270 --- /dev/null +++ b/packages/@tailwindcss-cli/tsup.config.ts @@ -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'], +}) diff --git a/packages/@tailwindcss-postcss/package.json b/packages/@tailwindcss-postcss/package.json index e9a6bc951..321aa5c19 100644 --- a/packages/@tailwindcss-postcss/package.json +++ b/packages/@tailwindcss-postcss/package.json @@ -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", diff --git a/packages/@tailwindcss-postcss/src/fixtures/example-project/src/relative-import.css b/packages/@tailwindcss-postcss/src/fixtures/example-project/src/relative-import.css new file mode 100644 index 000000000..48a30ab4d --- /dev/null +++ b/packages/@tailwindcss-postcss/src/fixtures/example-project/src/relative-import.css @@ -0,0 +1 @@ +@plugin '../plugin.js'; diff --git a/packages/@tailwindcss-postcss/src/index.test.ts b/packages/@tailwindcss-postcss/src/index.test.ts index 4d376db9a..07c674da0 100644 --- a/packages/@tailwindcss-postcss/src/index.test.ts +++ b/packages/@tailwindcss-postcss/src/index.test.ts @@ -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 } }), diff --git a/packages/@tailwindcss-postcss/src/index.ts b/packages/@tailwindcss-postcss/src/index.ts index 30a0828a2..1d85d1268 100644 --- a/packages/@tailwindcss-postcss/src/index.ts +++ b/packages/@tailwindcss-postcss/src/index.ts @@ -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)) + }, }, ], } diff --git a/packages/@tailwindcss-postcss/tsup.config.ts b/packages/@tailwindcss-postcss/tsup.config.ts new file mode 100644 index 000000000..08ec8092a --- /dev/null +++ b/packages/@tailwindcss-postcss/tsup.config.ts @@ -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'], +}) diff --git a/packages/@tailwindcss-vite/package.json b/packages/@tailwindcss-vite/package.json index fbe03c8bd..5d10eecd8 100644 --- a/packages/@tailwindcss-vite/package.json +++ b/packages/@tailwindcss-vite/package.json @@ -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": { diff --git a/packages/@tailwindcss-vite/src/index.ts b/packages/@tailwindcss-vite/src/index.ts index d8a0924c5..e5ef5ca8c 100644 --- a/packages/@tailwindcss-vite/src/index.ts +++ b/packages/@tailwindcss-vite/src/index.ts @@ -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') diff --git a/packages/@tailwindcss-vite/tsup.config.ts b/packages/@tailwindcss-vite/tsup.config.ts new file mode 100644 index 000000000..eaf99e82a --- /dev/null +++ b/packages/@tailwindcss-vite/tsup.config.ts @@ -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'], +}) diff --git a/packages/internal-postcss-fix-relative-paths/package.json b/packages/internal-postcss-fix-relative-paths/package.json new file mode 100644 index 000000000..2ffb3c7dd --- /dev/null +++ b/packages/internal-postcss-fix-relative-paths/package.json @@ -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" + } +} diff --git a/packages/internal-postcss-fix-relative-paths/src/fixtures/example-project/src/index.css b/packages/internal-postcss-fix-relative-paths/src/fixtures/example-project/src/index.css new file mode 100644 index 000000000..112e82e59 --- /dev/null +++ b/packages/internal-postcss-fix-relative-paths/src/fixtures/example-project/src/index.css @@ -0,0 +1,3 @@ +@content "./**/*.ts"; +@plugin "./plugin.js"; +@plugin "./what\"s-this.js"; diff --git a/packages/internal-postcss-fix-relative-paths/src/fixtures/example-project/src/invalid.css b/packages/internal-postcss-fix-relative-paths/src/fixtures/example-project/src/invalid.css new file mode 100644 index 000000000..9eeb9353e --- /dev/null +++ b/packages/internal-postcss-fix-relative-paths/src/fixtures/example-project/src/invalid.css @@ -0,0 +1,4 @@ +@plugin "/absolute/paths"; +@plugin "C:\Program Files\HAL 9000"; +@plugin "\\Media\Pictures\Worth\1000 words"; +@plugin "some-node-dep"; diff --git a/packages/internal-postcss-fix-relative-paths/src/fixtures/external-import/src/index.css b/packages/internal-postcss-fix-relative-paths/src/fixtures/external-import/src/index.css new file mode 100644 index 000000000..2c014767b --- /dev/null +++ b/packages/internal-postcss-fix-relative-paths/src/fixtures/external-import/src/index.css @@ -0,0 +1 @@ +@import '../../example-project/src/index.css'; diff --git a/packages/internal-postcss-fix-relative-paths/src/fixtures/external-import/src/invalid.css b/packages/internal-postcss-fix-relative-paths/src/fixtures/external-import/src/invalid.css new file mode 100644 index 000000000..b69d455c0 --- /dev/null +++ b/packages/internal-postcss-fix-relative-paths/src/fixtures/external-import/src/invalid.css @@ -0,0 +1 @@ +@import '../../example-project/src/invalid.css'; diff --git a/packages/internal-postcss-fix-relative-paths/src/fixtures/external-import/src/plugins-in-root.css b/packages/internal-postcss-fix-relative-paths/src/fixtures/external-import/src/plugins-in-root.css new file mode 100644 index 000000000..d6d5f082c --- /dev/null +++ b/packages/internal-postcss-fix-relative-paths/src/fixtures/external-import/src/plugins-in-root.css @@ -0,0 +1,5 @@ +@import './plugins-in-sibling.css'; + +@plugin './plugin-in-root.ts'; +@plugin '../plugin-in-root.ts'; +@plugin 'plugin-in-root'; diff --git a/packages/internal-postcss-fix-relative-paths/src/fixtures/external-import/src/plugins-in-sibling.css b/packages/internal-postcss-fix-relative-paths/src/fixtures/external-import/src/plugins-in-sibling.css new file mode 100644 index 000000000..5df3cb061 --- /dev/null +++ b/packages/internal-postcss-fix-relative-paths/src/fixtures/external-import/src/plugins-in-sibling.css @@ -0,0 +1,3 @@ +@plugin './plugin-in-sibling.ts'; +@plugin '../plugin-in-sibling.ts'; +@plugin 'plugin-in-sibling'; diff --git a/packages/internal-postcss-fix-relative-paths/src/index.test.ts b/packages/internal-postcss-fix-relative-paths/src/index.test.ts new file mode 100644 index 000000000..c66699c65 --- /dev/null +++ b/packages/internal-postcss-fix-relative-paths/src/index.test.ts @@ -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';" + `) + }) +}) diff --git a/packages/internal-postcss-fix-relative-paths/src/index.ts b/packages/internal-postcss-fix-relative-paths/src/index.ts new file mode 100644 index 000000000..0193284fe --- /dev/null +++ b/packages/internal-postcss-fix-relative-paths/src/index.ts @@ -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 = 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, + }, + } +} diff --git a/packages/internal-postcss-fix-relative-paths/tsconfig.json b/packages/internal-postcss-fix-relative-paths/tsconfig.json new file mode 100644 index 000000000..6ae022f65 --- /dev/null +++ b/packages/internal-postcss-fix-relative-paths/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../tsconfig.base.json", +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bcd2c2f73..282390651 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: