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:
parent
3e5745fbb2
commit
78f5b087d4
10 changed files with 226 additions and 40 deletions
11
CHANGELOG.md
11
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}"
|
||||
`)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue