Track source locations through @plugin and @config (#18329)

Fixes https://github.com/tailwindlabs/tailwindcss-forms/issues/182

Inside JS plugins and configs we weren't tracking source location info
so using things like `addBase(…)` could result in warnings inside Vite's
url rewriting PostCSS plugin because not all declarations had a source
location.

The goal here is for calls to `addBase` to generate CSS that points back
to the `@plugin` or `@config` that resulted in it being called.
This commit is contained in:
Jordan Pittman 2025-07-03 11:20:54 -04:00 • committed by GitHub
parent 9169d73aad
commit 2941a7b5c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 137 additions and 37 deletions

View file

@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Don't consider the global important state in `@apply` ([#18404](https://github.com/tailwindlabs/tailwindcss/pull/18404))
- Fix trailing `)` from interfering with extraction in Clojure keywords ([#18345](https://github.com/tailwindlabs/tailwindcss/pull/18345))
- Detect classes inside Elixir charlist, word list, and string sigils ([#18432](https://github.com/tailwindlabs/tailwindcss/pull/18432))
- Track source locations through `@plugin` and `@config` ([#18345](https://github.com/tailwindlabs/tailwindcss/pull/18345))
## [4.1.11] - 2025-06-26

View file

@ -1,6 +1,7 @@
import { Features } from '..'
import { styleRule, toCss, walk, WalkAction, type AstNode } from '../ast'
import type { DesignSystem } from '../design-system'
import type { SourceLocation } from '../source-maps/source'
import { segment } from '../utils/segment'
import { applyConfigToTheme } from './apply-config-to-theme'
import { applyKeyframesToTheme } from './apply-keyframes-to-theme'
@ -38,9 +39,16 @@ export async function applyCompatibilityHooks({
sources: { base: string; pattern: string; negated: boolean }[]
}) {
let features = Features.None
let pluginPaths: [{ id: string; base: string; reference: boolean }, CssPluginOptions | null][] =
[]
let configPaths: { id: string; base: string; reference: boolean }[] = []
let pluginPaths: [
{ id: string; base: string; reference: boolean; src: SourceLocation | undefined },
CssPluginOptions | null,
][] = []
let configPaths: {
id: string
base: string
reference: boolean
src: SourceLocation | undefined
}[] = []
walk(ast, (node, { parent, replaceWith, context }) => {
if (node.kind !== 'at-rule') return
@ -100,7 +108,12 @@ export async function applyCompatibilityHooks({
}
pluginPaths.push([
{ id: pluginPath, base: context.base as string, reference: !!context.reference },
{
id: pluginPath,
base: context.base as string,
reference: !!context.reference,
src: node.src,
},
Object.keys(options).length > 0 ? options : null,
])
@ -123,6 +136,7 @@ export async function applyCompatibilityHooks({
id: node.params.slice(1, -1),
base: context.base as string,
reference: !!context.reference,
src: node.src,
})
replaceWith([])
features |= Features.JsPluginCompat
@ -162,18 +176,19 @@ export async function applyCompatibilityHooks({
let [configs, pluginDetails] = await Promise.all([
Promise.all(
configPaths.map(async ({ id, base, reference }) => {
configPaths.map(async ({ id, base, reference, src }) => {
let loaded = await loadModule(id, base, 'config')
return {
path: id,
base: loaded.base,
config: loaded.module as UserConfig,
reference,
src,
}
}),
),
Promise.all(
pluginPaths.map(async ([{ id, base, reference }, pluginOptions]) => {
pluginPaths.map(async ([{ id, base, reference, src }, pluginOptions]) => {
let loaded = await loadModule(id, base, 'plugin')
return {
path: id,
@ -181,6 +196,7 @@ export async function applyCompatibilityHooks({
plugin: loaded.module as Plugin,
options: pluginOptions,
reference,
src,
}
}),
),
@ -215,6 +231,7 @@ function upgradeToFullPluginSupport({
base: string
config: UserConfig
reference: boolean
src: SourceLocation | undefined
}[]
pluginDetails: {
path: string
@ -222,6 +239,7 @@ function upgradeToFullPluginSupport({
plugin: Plugin
options: CssPluginOptions | null
reference: boolean
src: SourceLocation | undefined
}[]
}) {
let features = Features.None
@ -231,6 +249,7 @@ function upgradeToFullPluginSupport({
config: { plugins: [detail.plugin] },
base: detail.base,
reference: detail.reference,
src: detail.src,
}
}
@ -239,6 +258,7 @@ function upgradeToFullPluginSupport({
config: { plugins: [detail.plugin(detail.options)] },
base: detail.base,
reference: detail.reference,
src: detail.src,
}
}
@ -248,15 +268,32 @@ function upgradeToFullPluginSupport({
let userConfig = [...pluginConfigs, ...configs]
let { resolvedConfig } = resolveConfig(designSystem, [
{ config: createCompatConfig(designSystem.theme), base, reference: true },
{ config: createCompatConfig(designSystem.theme), base, reference: true, src: undefined },
...userConfig,
{ config: { plugins: [darkModePlugin] }, base, reference: true },
{ config: { plugins: [darkModePlugin] }, base, reference: true, src: undefined },
])
let { resolvedConfig: resolvedUserConfig, replacedThemeKeys } = resolveConfig(
designSystem,
userConfig,
)
let pluginApiConfig = {
designSystem,
ast,
resolvedConfig,
featuresRef: {
set current(value: number) {
features |= value
},
},
}
let sharedPluginApi = buildPluginApi({
...pluginApiConfig,
referenceMode: false,
src: undefined,
})
// Replace `resolveThemeValue` with a version that is backwards compatible
// with dot-notation but also aware of any JS theme configurations registered
// by plugins or JS config files. This is significantly slower than just
@ -270,7 +307,7 @@ function upgradeToFullPluginSupport({
return defaultResolveThemeValue(path, forceInline)
}
let resolvedValue = pluginApi.theme(path, undefined)
let resolvedValue = sharedPluginApi.theme(path, undefined)
if (Array.isArray(resolvedValue) && resolvedValue.length === 2) {
// When a tuple is returned, return the first element
@ -285,27 +322,17 @@ function upgradeToFullPluginSupport({
}
}
let pluginApiConfig = {
designSystem,
ast,
resolvedConfig,
featuresRef: {
set current(value: number) {
features |= value
},
},
}
for (let { handler, reference, src } of resolvedConfig.plugins) {
// Each plugin gets its own instance of the plugin API because nodes added
// to the AST may need to point to the `@config` or `@plugin` that they
// originated from
let api = buildPluginApi({
...pluginApiConfig,
referenceMode: reference ?? false,
src,
})
let pluginApi = buildPluginApi({ ...pluginApiConfig, referenceMode: false })
let referenceModePluginApi = undefined
for (let { handler, reference } of resolvedConfig.plugins) {
if (reference) {
referenceModePluginApi ||= buildPluginApi({ ...pluginApiConfig, referenceMode: true })
handler(referenceModePluginApi)
} else {
handler(pluginApi)
}
handler(api)
}
// Merge the user-configured theme keys into the design system. The compat

View file

@ -1,4 +1,5 @@
import type { DesignSystem } from '../../design-system'
import type { SourceLocation } from '../../source-maps/source'
import colors from '../colors'
import type { PluginWithConfig } from '../plugin-api'
import { createThemeFn } from '../plugin-functions'
@ -16,6 +17,7 @@ export interface ConfigFile {
base: string
config: UserConfig
reference: boolean
src: SourceLocation | undefined
}
interface ResolutionContext {
@ -131,7 +133,7 @@ export type PluginUtils = {
function extractConfigs(
ctx: ResolutionContext,
{ config, base, path, reference }: ConfigFile,
{ config, base, path, reference, src }: ConfigFile,
): void {
let plugins: PluginWithConfig[] = []
@ -140,17 +142,17 @@ function extractConfigs(
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(), reference })
plugins.push({ ...plugin(), reference, src })
} 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, reference })
plugins.push({ ...plugin, reference, src })
} else {
// Just a plain function without using the plugin(…) API
plugins.push({ handler: plugin, reference })
plugins.push({ handler: plugin, reference, src })
}
}
@ -162,7 +164,7 @@ function extractConfigs(
}
for (let preset of config.presets ?? []) {
extractConfigs(ctx, { path, base, config: preset, reference })
extractConfigs(ctx, { path, base, config: preset, reference, src })
}
// Apply configs from plugins
@ -170,7 +172,13 @@ function extractConfigs(
ctx.plugins.push(plugin)
if (plugin.config) {
extractConfigs(ctx, { path, base, config: plugin.config, reference: !!plugin.reference })
extractConfigs(ctx, {
path,
base,
config: plugin.config,
reference: !!plugin.reference,
src: plugin.src ?? src,
})
}
}

View file

@ -5,6 +5,7 @@ import type { Candidate, CandidateModifier, NamedUtilityValue } from '../candida
import { substituteFunctions } from '../css-functions'
import * as CSS from '../css-parser'
import type { DesignSystem } from '../design-system'
import type { SourceLocation } from '../source-maps/source'
import { withAlpha } from '../utilities'
import { DefaultMap } from '../utils/default-map'
import { escape } from '../utils/escape'
@ -24,6 +25,7 @@ export type PluginWithConfig = {
/** @internal */
reference?: boolean
src?: SourceLocation | undefined
}
export type PluginWithOptions<T> = {
(options?: T): PluginWithConfig
@ -93,19 +95,25 @@ export function buildPluginApi({
resolvedConfig,
featuresRef,
referenceMode,
src,
}: {
designSystem: DesignSystem
ast: AstNode[]
resolvedConfig: ResolvedConfig
featuresRef: { current: Features }
referenceMode: boolean
src: SourceLocation | undefined
}): PluginAPI {
let api: PluginAPI = {
addBase(css) {
if (referenceMode) return
let baseNodes = objectToAst(css)
featuresRef.current |= substituteFunctions(baseNodes, designSystem)
ast.push(atRule('@layer', 'base', baseNodes))
let rule = atRule('@layer', 'base', baseNodes)
walk([rule], (node) => {
node.src = src
})
ast.push(rule)
},
addVariant(name, variant) {
@ -255,7 +263,11 @@ export function buildPluginApi({
for (let [name, css] of entries) {
if (name.startsWith('@keyframes ')) {
if (!referenceMode) {
ast.push(rule(name, objectToAst(css)))
let keyframes = rule(name, objectToAst(css))
walk([keyframes], (node) => {
node.src = src
})
ast.push(keyframes)
}
continue
}

View file

@ -419,3 +419,55 @@ test('license comments with new lines preserve source locations', async ({ expec
'input.css: 2:11 <- 2:11',
])
})
test('Source locations for `addBase` point to the `@plugin` that generated them', async ({
expect,
}) => {
let { sources, annotations } = await run({
input: dedent`
@plugin "./plugin.js";
@config "./config.js";
`,
options: {
async loadModule(id, base) {
if (id === './plugin.js') {
return {
module: createPlugin(({ addBase }) => {
addBase({ body: { color: 'red' } })
}),
base,
path: '',
}
}
if (id === './config.js') {
return {
module: {
plugins: [
createPlugin(({ addBase }) => {
addBase({ body: { color: 'green' } })
}),
],
},
base,
path: '',
}
}
throw new Error(`unknown module ${id}`)
},
},
})
expect(sources).toEqual(['input.css'])
expect(annotations).toEqual([
//
'input.css: 1:0-12 <- 1:0-21',
'input.css: 2:2-7 <- 1:0-21',
'input.css: 3:4-14 <- 1:0-21',
'input.css: 6:0-12 <- 2:0-21',
'input.css: 7:2-7 <- 2:0-21',
'input.css: 8:4-16 <- 2:0-21',
])
})