Add @import "…" reference (#15228)

Closes #15219

This PR adds a new feature, `@import "…" reference` that can be used to
load Tailwind CSS configuration files without adding any style rules to
the CSS.

The idea is that you can use this in combination with your Tailwind CSS
root file when you need to have access to your full CSS config outside
of the main stylesheet. A common example is for Vue, Svelte, or CSS
modules:

```css
@import "./tailwind.css" reference;

.link {
  @apply underline;
}
```

Importing a file as a reference will convert all `@theme` block to be
`reference`, so no CSS variables will be emitted. Furthermore it will
strip out all custom styles from the stylesheet. Furthermore plugins
registered via `@plugin` or `@config` inside reference-mode files will
not add any content to the CSS file via `addBase()`.

## Test Plan

Added unit test for when we handle the import resolution and when
`postcss-import` does it outside of Tailwind CSS. I also changed the
Svelte and Vue integration tests to use this new syntax to ensure it
works end to end.

---------

Co-authored-by: Jordan Pittman <jordan@cryptica.me>
This commit is contained in:
Philipp Spiess 2024-12-03 14:15:24 +01:00 • committed by GitHub
parent 3e5745fbb2
commit 78f5b087d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 226 additions and 40 deletions

View file

@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- Parallelize parsing of individual source files ([#15270](https://github.com/tailwindlabs/tailwindcss/pull/15270))
- Support Vite 6 in the Vite plugin ([#15274](https://github.com/tailwindlabs/tailwindcss/issues/15274))
- Add a new `@import "…" reference` syntax for only importing the Tailwind CSS configurations of a stylesheets ([#15228](https://github.com/tailwindlabs/tailwindcss/pull/15228))
### Fixed
- Ensure absolute `url()`s inside imported CSS files are not rebased when using `@tailwindcss/vite` ([#15275](https://github.com/tailwindlabs/tailwindcss/pull/15275))
@ -15,11 +21,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Ensure other plugins can run after `@tailwindcss/postcss` ([#15273](https://github.com/tailwindlabs/tailwindcss/pull/15273))
- Rebase `url()` inside imported CSS files when using Vite with the `@tailwindcss/postcss` extension ([#15273](https://github.com/tailwindlabs/tailwindcss/pull/15273))
### Added
- Parallelize parsing of individual source files ([#15270](https://github.com/tailwindlabs/tailwindcss/pull/15270))
- Support Vite 6 in the Vite plugin ([#15274](https://github.com/tailwindlabs/tailwindcss/issues/15274))
## [4.0.0-beta.4] - 2024-11-29
### Fixed

View file

@ -48,10 +48,7 @@ test(
target: document.body,
})
`,
'src/index.css': css`
@import 'tailwindcss/theme' theme(reference);
@import 'tailwindcss/utilities';
`,
'src/index.css': css`@import 'tailwindcss' reference;`,
'src/App.svelte': html`
<script>
import './index.css'

View file

@ -45,15 +45,13 @@ test(
`,
'src/App.vue': html`
<style>
@import 'tailwindcss/utilities';
@import 'tailwindcss/theme' theme(reference);
@import 'tailwindcss' reference;
.foo {
@apply text-red-500;
}
</style>
<style scoped>
@import 'tailwindcss/utilities';
@import 'tailwindcss/theme' theme(reference);
@import 'tailwindcss' reference;
:deep(.bar) {
color: red;
}

View file

@ -29,7 +29,7 @@ export type Comment = {
export type Context = {
kind: 'context'
context: Record<string, string>
context: Record<string, string | boolean>
nodes: AstNode[]
}
@ -82,7 +82,7 @@ export function comment(value: string): Comment {
}
}
export function context(context: Record<string, string>, nodes: AstNode[]): Context {
export function context(context: Record<string, string | boolean>, nodes: AstNode[]): Context {
return {
kind: 'context',
context,
@ -115,12 +115,12 @@ export function walk(
utils: {
parent: AstNode | null
replaceWith(newNode: AstNode | AstNode[]): void
context: Record<string, string>
context: Record<string, string | boolean>
path: AstNode[]
},
) => void | WalkAction,
parentPath: AstNode[] = [],
context: Record<string, string> = {},
context: Record<string, string | boolean> = {},
) {
for (let i = 0; i < ast.length; i++) {
let node = ast[i]
@ -175,12 +175,12 @@ export function walkDepth(
utils: {
parent: AstNode | null
path: AstNode[]
context: Record<string, string>
context: Record<string, string | boolean>
replaceWith(newNode: AstNode[]): void
},
) => void,
parentPath: AstNode[] = [],
context: Record<string, string> = {},
context: Record<string, string | boolean> = {},
) {
for (let i = 0; i < ast.length; i++) {
let node = ast[i]

View file

@ -34,8 +34,9 @@ export async function applyCompatibilityHooks({
globs: { origin?: string; pattern: string }[]
}) {
let features = Features.None
let pluginPaths: [{ id: string; base: string }, CssPluginOptions | null][] = []
let configPaths: { id: string; base: string }[] = []
let pluginPaths: [{ id: string; base: string; reference: boolean }, CssPluginOptions | null][] =
[]
let configPaths: { id: string; base: string; reference: boolean }[] = []
walk(ast, (node, { parent, replaceWith, context }) => {
if (node.kind !== 'at-rule') return
@ -95,7 +96,7 @@ export async function applyCompatibilityHooks({
}
pluginPaths.push([
{ id: pluginPath, base: context.base },
{ id: pluginPath, base: context.base as string, reference: !!context.reference },
Object.keys(options).length > 0 ? options : null,
])
@ -114,7 +115,11 @@ export async function applyCompatibilityHooks({
throw new Error('`@config` cannot be nested.')
}
configPaths.push({ id: node.params.slice(1, -1), base: context.base })
configPaths.push({
id: node.params.slice(1, -1),
base: context.base as string,
reference: !!context.reference,
})
replaceWith([])
features |= Features.JsPluginCompat
return
@ -153,23 +158,25 @@ export async function applyCompatibilityHooks({
let [configs, pluginDetails] = await Promise.all([
Promise.all(
configPaths.map(async ({ id, base }) => {
configPaths.map(async ({ id, base, reference }) => {
let loaded = await loadModule(id, base, 'config')
return {
path: id,
base: loaded.base,
config: loaded.module as UserConfig,
reference,
}
}),
),
Promise.all(
pluginPaths.map(async ([{ id, base }, pluginOptions]) => {
pluginPaths.map(async ([{ id, base, reference }, pluginOptions]) => {
let loaded = await loadModule(id, base, 'plugin')
return {
path: id,
base: loaded.base,
plugin: loaded.module as Plugin,
options: pluginOptions,
reference,
}
}),
),
@ -203,22 +210,32 @@ function upgradeToFullPluginSupport({
path: string
base: string
config: UserConfig
reference: boolean
}[]
pluginDetails: {
path: string
base: string
plugin: Plugin
options: CssPluginOptions | null
reference: boolean
}[]
}) {
let features = Features.None
let pluginConfigs = pluginDetails.map((detail) => {
if (!detail.options) {
return { config: { plugins: [detail.plugin] }, base: detail.base }
return {
config: { plugins: [detail.plugin] },
base: detail.base,
reference: detail.reference,
}
}
if ('__isOptionsFunction' in detail.plugin) {
return { config: { plugins: [detail.plugin(detail.options)] }, base: detail.base }
return {
config: { plugins: [detail.plugin(detail.options)] },
base: detail.base,
reference: detail.reference,
}
}
throw new Error(`The plugin "${detail.path}" does not accept options`)
@ -227,9 +244,9 @@ function upgradeToFullPluginSupport({
let userConfig = [...pluginConfigs, ...configs]
let { resolvedConfig } = resolveConfig(designSystem, [
{ config: createCompatConfig(designSystem.theme), base },
{ config: createCompatConfig(designSystem.theme), base, reference: true },
...userConfig,
{ config: { plugins: [darkModePlugin] }, base },
{ config: { plugins: [darkModePlugin] }, base, reference: true },
])
let { resolvedConfig: resolvedUserConfig, replacedThemeKeys } = resolveConfig(
designSystem,
@ -242,8 +259,8 @@ function upgradeToFullPluginSupport({
},
})
for (let { handler } of resolvedConfig.plugins) {
handler(pluginApi)
for (let { handler, reference } of resolvedConfig.plugins) {
handler(reference ? { ...pluginApi, addBase: () => {} } : pluginApi)
}
// Merge the user-configured theme keys into the design system. The compat

View file

@ -15,6 +15,7 @@ export interface ConfigFile {
path?: string
base: string
config: UserConfig
reference: boolean
}
interface ResolutionContext {
@ -128,7 +129,10 @@ export type PluginUtils = {
colors: typeof colors
}
function extractConfigs(ctx: ResolutionContext, { config, base, path }: ConfigFile): void {
function extractConfigs(
ctx: ResolutionContext,
{ config, base, path, reference }: ConfigFile,
): void {
let plugins: PluginWithConfig[] = []
// Normalize plugins so they share the same shape
@ -136,17 +140,17 @@ function extractConfigs(ctx: ResolutionContext, { config, base, path }: ConfigFi
if ('__isOptionsFunction' in plugin) {
// Happens with `plugin.withOptions()` when no options were passed:
// e.g. `require("my-plugin")` instead of `require("my-plugin")(options)`
plugins.push(plugin())
plugins.push({ ...plugin(), reference })
} else if ('handler' in plugin) {
// Happens with `plugin(…)`:
// e.g. `require("my-plugin")`
//
// or with `plugin.withOptions()` when the user passed options:
// e.g. `require("my-plugin")(options)`
plugins.push(plugin)
plugins.push({ ...plugin, reference })
} else {
// Just a plain function without using the plugin(…) API
plugins.push({ handler: plugin })
plugins.push({ handler: plugin, reference })
}
}
@ -158,7 +162,7 @@ function extractConfigs(ctx: ResolutionContext, { config, base, path }: ConfigFi
}
for (let preset of config.presets ?? []) {
extractConfigs(ctx, { path, base, config: preset })
extractConfigs(ctx, { path, base, config: preset, reference })
}
// Apply configs from plugins
@ -166,7 +170,7 @@ function extractConfigs(ctx: ResolutionContext, { config, base, path }: ConfigFi
ctx.plugins.push(plugin)
if (plugin.config) {
extractConfigs(ctx, { path, base, config: plugin.config })
extractConfigs(ctx, { path, base, config: plugin.config, reference: !!plugin.reference })
}
}

View file

@ -1490,6 +1490,52 @@ describe('theme', async () => {
})
})
describe('addBase', () => {
test('does not create rules when imported via `@import "…" reference`', async () => {
let input = css`
@tailwind utilities;
@plugin "outside";
@import './inside.css' reference;
`
let compiler = await compile(input, {
loadModule: async (id, base) => {
if (id === 'inside') {
return {
base,
module: plugin(function ({ addBase }) {
addBase({ inside: { color: 'red' } })
}),
}
}
return {
base,
module: plugin(function ({ addBase }) {
addBase({ outside: { color: 'red' } })
}),
}
},
async loadStylesheet() {
return {
content: css`
@plugin "inside";
`,
base: '',
}
},
})
expect(compiler.build([])).toMatchInlineSnapshot(`
"@layer base {
outside {
color: red;
}
}
"
`)
})
})
describe('addVariant', () => {
test('addVariant with string selector', async () => {
let { build } = await compile(

View file

@ -17,7 +17,13 @@ import * as SelectorParser from './selector-parser'
export type Config = UserConfig
export type PluginFn = (api: PluginAPI) => void
export type PluginWithConfig = { handler: PluginFn; config?: UserConfig }
export type PluginWithConfig = {
handler: PluginFn;
config?: UserConfig;
/** @internal */
reference?: boolean
}
export type PluginWithOptions<T> = {
(options?: T): PluginWithConfig
__isOptionsFunction: true

View file

@ -3088,3 +3088,89 @@ it("should error when `layer(…)` is used, but it's not the first param", async
`[Error: \`layer(…)\` in an \`@import\` should come before any other functions or conditions]`,
)
})
describe('`@import "…" reference`', () => {
test('recursively removes styles', async () => {
let loadStylesheet = async (id: string, base: string) => {
if (id === './foo/baz.css') {
return {
content: css`
.foo {
color: red;
}
@utility foo {
color: red;
}
@theme {
--breakpoint-md: 768px;
}
@variant hocus (&:hover, &:focus);
`,
base: '/root/foo',
}
}
return {
content: css`
@import './foo/baz.css';
`,
base: '/root/foo',
}
}
await expect(
compileCss(
`
@import './foo/bar.css' reference;
.bar {
@apply md:hocus:foo;
}
`,
[],
{ loadStylesheet },
),
).resolves.toMatchInlineSnapshot(`
"@media (width >= 768px) {
.bar:hover, .bar:focus {
color: red;
}
}"
`)
})
test('removes styles when the import resolver was handled outside of Tailwind CSS', async () => {
await expect(
compileCss(
`
@media reference {
@layer theme {
@theme {
--breakpoint-md: 48rem;
}
.foo {
color: red;
}
}
@utility foo {
color: red;
}
@variant hocus (&:hover, &:focus);
}
.bar {
@apply md:hocus:foo;
}
`,
[],
),
).resolves.toMatchInlineSnapshot(`
"@layer theme;
@media (width >= 48rem) {
.bar:hover, .bar:focus {
color: red;
}
}"
`)
})
})

View file

@ -157,7 +157,7 @@ async function parseCss(
}
root = {
base: context.sourceBase ?? context.base,
base: (context.sourceBase as string) ?? (context.base as string),
pattern: path.slice(1, -1),
}
}
@ -212,7 +212,7 @@ async function parseCss(
) {
throw new Error('`@source` paths must be quoted.')
}
globs.push({ base: context.base, pattern: path.slice(1, -1) })
globs.push({ base: context.base as string, pattern: path.slice(1, -1) })
replaceWith([])
return
}
@ -364,6 +364,37 @@ async function parseCss(
important = true
}
// Handle `@import "…" reference`
else if (param === 'reference') {
walk(node.nodes, (child, { replaceWith }) => {
if (child.kind !== 'at-rule') {
replaceWith([])
return WalkAction.Skip
}
switch (child.name) {
case '@theme': {
let themeParams = segment(child.params, ' ')
if (!themeParams.includes('reference')) {
child.params = (child.params === '' ? '' : ' ') + 'reference'
}
return WalkAction.Skip
}
case '@import':
case '@config':
case '@plugin':
case '@variant':
case '@utility': {
return WalkAction.Skip
}
// Other at-rules, like `@media`, `@supports`, or `@layer` should
// be recursively traversed as these might be inserted by the
// `@import` resolution.
}
})
node.nodes = [contextNode({ reference: true }, node.nodes)]
}
//
else {
unknownParams.push(param)